WAYFARER PROJECT REVIEW Generated: 2026-10-02 18:28:00 Project root: /home/sdllcb5/public_html/wayfarer Files included: 546 ================================================================================ ================================================================================ FILE: _play_recovery_backups/20260922-164514/play/includes/runtime.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT id FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $requestedId ]); $id = $stmt->fetchColumn(); if ($id !== false) { return (int)$id; } throw new RuntimeException( 'The requested game-user record does not exist.' ); } $id = $pdo ->query( " SELECT id FROM wf_game_users ORDER BY id LIMIT 1 " ) ->fetchColumn(); if ($id === false) { throw new RuntimeException( 'No wf_game_users record exists.' ); } return (int)$id; } /** * Load one game-user runtime state. */ function wayfarerRuntimeLoad( PDO $pdo, int $gameUserId ): array { $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'state' => $state, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /** * Advance exactly one tick. * * This is intentionally the entire v14 game engine: * * state.runtime.tick += 1 * * The transaction and row lock are already in place so later work can add * movement/effects without changing the request architecture. */ function wayfarerRuntimeAdvanceTick( PDO $pdo, int $gameUserId ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } $currentTick = (int)( $state['runtime']['tick'] ?? 0 ); $state['runtime']['tick'] = $currentTick + 1; $state['runtime']['last_tick_at'] = gmdate('c'); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'tick' => (int)$state['runtime']['tick'], 'state' => $state, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /* |-------------------------------------------------------------------------- | WAYFARER_RUNTIME_V15 |-------------------------------------------------------------------------- | | Starting Region + applicable Trait helpers. | */ /** * Decode authored config JSON safely. */ function wayfarerRuntimeDecodeConfig($rawConfig): array { if (is_array($rawConfig)) { return $rawConfig; } if ($rawConfig === null || $rawConfig === '') { return []; } $decoded = json_decode( (string)$rawConfig, true ); return is_array($decoded) ? $decoded : []; } /** * Return only active authored Trait definitions. */ function wayfarerRuntimeActiveTraitDefinitions( array $config ): array { $traits = $config['traits'] ?? []; if (!is_array($traits)) { return []; } $active = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( array_key_exists( 'active', $trait ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { /* * System Traits created by the generic builder may have a * stable system key even when an older stored record did not * receive an ordinary generated tr_... id. */ $traitId = trim( (string)( $trait['system_key'] ?? '' ) ); } if ($traitId === '') { continue; } $trait['_runtime_id'] = $traitId; $active[] = $trait; } return $active; } /** * Initial runtime value for an authored Trait. */ function wayfarerRuntimeTraitInitialValue( array $trait ) { if ( array_key_exists( 'initial', $trait ) ) { return $trait['initial']; } /* * System Traits use the same stored Trait collection. The builder * writes their current authored starting value as initial; this * fallback simply keeps malformed/older data deterministic. */ $type = (string)( $trait['type'] ?? '' ); if ( $type === 'boolean' || $type === 'binary' ) { return 0; } if ($type === 'number') { return 0; } return null; } /** * Recursively find plausible authored starting-Region values. * * This is a compatibility reader for the existing Game Settings config. * Runtime itself normalizes the result to navigation.region_id. */ function wayfarerRuntimeFindStartRegionCandidates( array $value, array $path = [] ): array { $candidates = []; foreach ($value as $key => $child) { $keyString = (string)$key; $childPath = array_merge( $path, [$keyString] ); if (is_array($child)) { $candidates = array_merge( $candidates, wayfarerRuntimeFindStartRegionCandidates( $child, $childPath ) ); continue; } if ( !is_int($child) && !is_float($child) && !is_string($child) ) { continue; } $scalar = trim( (string)$child ); if ( $scalar === '' || !ctype_digit($scalar) ) { continue; } $fullPath = strtolower( implode( '.', $childPath ) ); $keyLower = strtolower( $keyString ); $explicitNames = [ 'starting_region_id', 'start_region_id', 'initial_region_id', 'starting_region', 'start_region', 'initial_region', ]; $isExplicit = in_array( $keyLower, $explicitNames, true ); $pathLooksLikeStart = ( strpos( $fullPath, 'start' ) !== false || strpos( $fullPath, 'initial' ) !== false ) && strpos( $fullPath, 'region' ) !== false; if ( $isExplicit || $pathLooksLikeStart ) { $candidates[] = [ 'path' => implode( '.', $childPath ), 'region_id' => (int)$scalar, ]; } } return $candidates; } /** * Resolve the authored starting Region and verify it belongs to this Game. */ function wayfarerRuntimeResolveStartingRegion( PDO $pdo, int $gameId, array $gameConfig ): ?array { $candidates = wayfarerRuntimeFindStartRegionCandidates( $gameConfig ); if (!$candidates) { return null; } $seen = []; foreach ($candidates as $candidate) { $regionId = (int)$candidate['region_id']; if ( $regionId <= 0 || isset($seen[$regionId]) ) { continue; } $seen[$regionId] = true; $stmt = $pdo->prepare( " SELECT id, game_id, name, navigation_type, config, status FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); if ($region) { $region['_starting_region_config_path'] = $candidate['path']; return $region; } } return null; } /** * Load a specific Region belonging to the current Game. */ function wayfarerRuntimeLoadRegion( PDO $pdo, int $gameId, int $regionId ): ?array { if ($regionId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT id, game_id, name, navigation_type, config, status FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); return $region ?: null; } /** * Initialize any missing Trait values without resetting existing runtime * values. Returns true when state changed. */ function wayfarerRuntimeInitializeTraitValues( array &$targetValues, array $traitDefinitions ): bool { $changed = false; foreach ($traitDefinitions as $trait) { $traitId = (string)( $trait['_runtime_id'] ?? '' ); if ($traitId === '') { continue; } if ( array_key_exists( $traitId, $targetValues ) ) { continue; } $targetValues[$traitId] = wayfarerRuntimeTraitInitialValue( $trait ); $changed = true; } return $changed; } /** * Ensure the playthrough has its first applicable gameplay context: * * Game * + current Region * * Starting Region is consulted ONLY when runtime has no current Region. */ function wayfarerRuntimeEnsureInitialContext( PDO $pdo, int $gameUserId ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $gameUser = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$gameUser) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $gameId = (int)$gameUser['game_id']; $state = wayfarerRuntimeDecodeState( $gameUser['state'] ); $gameStmt = $pdo->prepare( " SELECT id, name, config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $game = $gameStmt->fetch( PDO::FETCH_ASSOC ); if (!$game) { throw new RuntimeException( 'Game not found for runtime state.' ); } $gameConfig = wayfarerRuntimeDecodeConfig( $game['config'] ); $gameTraitDefinitions = wayfarerRuntimeActiveTraitDefinitions( $gameConfig ); $changed = false; if ( !isset($state['game']) || !is_array($state['game']) ) { $state['game'] = []; $changed = true; } if ( !isset($state['game']['traits']) || !is_array($state['game']['traits']) ) { $state['game']['traits'] = []; $changed = true; } if ( wayfarerRuntimeInitializeTraitValues( $state['game']['traits'], $gameTraitDefinitions ) ) { $changed = true; } // ------------------------------------------------------------ // CURRENT / STARTING REGION // ------------------------------------------------------------ if ( !isset($state['navigation']) || !is_array($state['navigation']) ) { $state['navigation'] = []; $changed = true; } $currentRegionId = (int)( $state['navigation']['region_id'] ?? 0 ); $region = null; $startingRegionPath = null; if ($currentRegionId > 0) { $region = wayfarerRuntimeLoadRegion( $pdo, $gameId, $currentRegionId ); } /* * Runtime has no valid current Region yet, so use authored start. */ if (!$region) { $region = wayfarerRuntimeResolveStartingRegionV151($pdo, $gameId); if ($region) { $currentRegionId = (int)$region['id']; $state['navigation']['region_id'] = $currentRegionId; if ( !array_key_exists( 'position', $state['navigation'] ) ) { $state['navigation']['position'] = 0; } $startingRegionPath = $region['_starting_region_config_path'] ?? null; $changed = true; } } // ------------------------------------------------------------ // CURRENT REGION TRAITS // ------------------------------------------------------------ $regionTraitDefinitions = []; if ($region) { $regionConfig = wayfarerRuntimeDecodeConfig( $region['config'] ); $regionTraitDefinitions = wayfarerRuntimeActiveTraitDefinitions( $regionConfig ); if ( !isset($state['regions']) || !is_array($state['regions']) ) { $state['regions'] = []; $changed = true; } $regionKey = (string)$currentRegionId; if ( !isset($state['regions'][$regionKey]) || !is_array( $state['regions'][$regionKey] ) ) { $state['regions'][$regionKey] = []; $changed = true; } if ( !isset( $state['regions'][$regionKey]['traits'] ) || !is_array( $state['regions'][$regionKey]['traits'] ) ) { $state['regions'][$regionKey]['traits'] = []; $changed = true; } if ( wayfarerRuntimeInitializeTraitValues( $state['regions'][$regionKey]['traits'], $regionTraitDefinitions ) ) { $changed = true; } } // ------------------------------------------------------------ // SAVE ONLY WHEN SOMETHING CHANGED // ------------------------------------------------------------ if ($changed) { $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ wayfarerRuntimeEncodeState( $state ), $gameUserId, ]); } $pdo->commit(); return [ 'game_user_id' => (int)$gameUser['id'], 'game_id' => $gameId, 'user_id' => (int)$gameUser['user_id'], 'game' => $game, 'game_config' => $gameConfig, 'game_trait_definitions' => $gameTraitDefinitions, 'region' => $region, 'region_trait_definitions' => $regionTraitDefinitions, 'starting_region_config_path' => $startingRegionPath, 'state' => $state, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Build display rows for the currently applicable Trait scopes. * * v15: * - Game * - current Region */ function wayfarerRuntimeApplicableTraitRows( array $context ): array { $rows = []; $state = $context['state'] ?? []; foreach ( $context['game_trait_definitions'] ?? [] as $trait ) { $traitId = (string)( $trait['_runtime_id'] ?? '' ); if ($traitId === '') { continue; } $rows[] = [ 'scope' => 'Game', 'name' => (string)( $trait['name'] ?? $trait['label'] ?? $traitId ), 'value' => $state['game']['traits'][$traitId] ?? null, 'type' => (string)( $trait['type'] ?? '' ), ]; } $region = $context['region'] ?? null; if ($region) { $regionId = (int)$region['id']; $regionKey = (string)$regionId; $scopeLabel = 'Region: ' . ( (string)( $region['name'] ?? ('#' . $regionId) ) ); foreach ( $context['region_trait_definitions'] ?? [] as $trait ) { $traitId = (string)( $trait['_runtime_id'] ?? '' ); if ($traitId === '') { continue; } $rows[] = [ 'scope' => $scopeLabel, 'name' => (string)( $trait['name'] ?? $trait['label'] ?? $traitId ), 'value' => $state['regions'][$regionKey]['traits'][$traitId] ?? null, 'type' => (string)( $trait['type'] ?? '' ), ]; } } return $rows; } /* |-------------------------------------------------------------------------- | WAYFARER_RUNTIME_V151_START_REGION |-------------------------------------------------------------------------- | | Temporary development rule: | the first active Region in the Game is the starting Region. | | An explicit authored starting-region setting can replace this later. | */ function wayfarerRuntimeResolveStartingRegionV151( PDO $pdo, int $gameId ): ?array { $stmt = $pdo->prepare( " SELECT * FROM wf_regions WHERE game_id = ? AND status = 'active' ORDER BY id LIMIT 1 " ); $stmt->execute([ $gameId ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); return $region ?: null; } ================================================================================ FILE: _play_recovery_backups/20260922-164514/play/index.php ================================================================================ prepare(" SELECT g.id AS game_id, g.name AS game_name, g.slug AS game_slug, g.config AS game_config, gu.id AS game_user_id, gu.role AS game_user_role, gu.state, u.username, u.display_name FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id INNER JOIN wf_users u ON u.id = gu.user_id WHERE gu.game_id = ? AND gu.user_id = ? LIMIT 1 "); $stmt->execute([ $gameId, $userId ]); $gameUser = $stmt->fetch(); if (!$gameUser) { die('Game user not found.'); } // ================================================================ // RUNTIME STATE // ================================================================ $state = null; if (!empty($gameUser['state'])) { $decodedState = json_decode( $gameUser['state'], true ); if (is_array($decodedState)) { $state = $decodedState; } } // ================================================================ // INITIALIZE GAME // ================================================================ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'initialize' && $state === null ) { // ------------------------------------------------------------ // LOAD AUTHORED GAME CONFIG // ------------------------------------------------------------ $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } // ------------------------------------------------------------ // INITIALIZE GAME TRAITS // ------------------------------------------------------------ $gameTraits = []; foreach ($gameConfig['traits'] ?? [] as $trait) { if ( empty($trait['id']) || !array_key_exists('initial', $trait) ) { continue; } $gameTraits[$trait['id']] = $trait['initial']; } // ------------------------------------------------------------ // BUILD INITIAL RUNTIME STATE // ------------------------------------------------------------ $state = [ 'game' => [ 'traits' => $gameTraits ] ]; // ------------------------------------------------------------ // SAVE RUNTIME STATE // ------------------------------------------------------------ $stateJson = json_encode( $state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $stmt = $pdo->prepare(" UPDATE wf_game_users SET state = ? WHERE id = ? "); $stmt->execute([ $stateJson, $gameUser['game_user_id'] ]); // Reload so refresh cannot repeat the POST. header('Location: index.php'); exit; } // ================================================================ // AUTHOR MODE: GAME TRAIT DEFINITIONS // ================================================================ $gameTraitDefinitions = []; $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } foreach ($gameConfig['traits'] ?? [] as $trait) { if (empty($trait['id'])) { continue; } $gameTraitDefinitions[$trait['id']] = $trait; } ?> <?= htmlspecialchars($gameUser['game_name']) ?>

Player:


Game Not Initialized

This game-user record exists, but no runtime state has been created yet.

Game State

Runtime state has been initialized.


Author Mode

Open Wayfarer Builder

Runtime Debug

================================================================================ FILE: _update_backups/v100-20260930-191021/admin/builder.php ================================================================================ prepare(" SELECT id, name, config FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; /* WAYFARER_STORYLET_BUILDER_V56 * A + New Branch Storylet link can preselect its parent and choose a sensible * sibling order without making parent_storylet_id part of the generic request. */ if ( $type === 'storylets' && !$id && !empty($_GET['parent_storylet_id']) ) { $requestedParentId = (int)$_GET['parent_storylet_id']; $parentStmt = $pdo->prepare( " SELECT id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $parentStmt->execute([ $requestedParentId, $gameId, ]); if ($parentStmt->fetchColumn()) { $record['parent_storylet_id'] = $requestedParentId; $orderStmt = $pdo->prepare( " SELECT COALESCE(MAX(branch_sort_order), 0) FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? " ); $orderStmt->execute([ $gameId, $requestedParentId, ]); $record['branch_sort_order'] = (int)$orderStmt->fetchColumn() + 10; } } // ================================================================ // SAVE / DELETE / DUPLICATE // WAYFARER_STORYLET_DUPLICATE_V83 // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'duplicate') { $duplicateId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; if ( ($definition['table'] ?? '') !== 'wf_storylets' ) { $error = 'Only Storylets can be duplicated.'; } else { $duplicateResult = duplicateStoryletRecord( $pdo, $gameId, $duplicateId ); $error = $duplicateResult['error']; if ($duplicateResult['duplicated']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) . '&id=' . (int)$duplicateResult['id'] . '&tab=basics' . '&duplicated=1' ); exit; } } } elseif ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { /* * Media Library associations are applied only after the authored * object has saved successfully and therefore has a stable ID. */ $savedRecordId = (int)$saveResult['id']; $mediaResult = applyBuilderMediaSelections( $pdo, $gameId, $definition['table'], $savedRecordId, $_POST, $_FILES ); if (!$mediaResult['saved']) { /* * The authored object itself has already saved. Keep the * editor on that record and report the media failure * accurately rather than pretending the object save failed. */ $id = $savedRecordId; $record['id'] = $savedRecordId; $error = 'Record saved, but media was not changed: ' . $mediaResult['error']; } else { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) . '&id=' . (int)$saveResult['id'] . '&tab=' . rawurlencode( (string)($_POST['active_tab'] ?? 'basics') ) ); exit; } } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder

================================================================================ FILE: _update_backups/v100-20260930-191021/assets/css/status-subject-effects.css ================================================================================ /* WAYFARER_STATUS_SUBJECT_EFFECTS_UI_V89 */ .wayfarer-status-stage-effects, .wayfarer-status-outcome-effects { margin-top: 16px; } .wayfarer-status-effects-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--wf-border); } .wayfarer-status-effects-heading small { display: block; margin-top: 2px; } .wayfarer-status-effect { display: grid; grid-template-columns: minmax(170px, 1fr) minmax(170px, 1fr) minmax(120px, 0.7fr) minmax(120px, 0.7fr) minmax(110px, 0.65fr) minmax(110px, 0.65fr) auto; gap: 10px; align-items: end; margin-top: 10px; padding: 11px; border: 1px solid var(--wf-border); border-radius: 7px; background: #fff; } .wayfarer-status-effect label { display: grid; gap: 5px; min-width: 0; } .wayfarer-status-effect select, .wayfarer-status-effect input { width: 100%; min-width: 0; } .wayfarer-status-effect-inline-number { display: grid; grid-template-columns: minmax(70px, 1fr) auto; gap: 6px; align-items: center; } .wayfarer-status-effect > button { align-self: end; } @media (max-width: 1150px) { .wayfarer-status-effect { grid-template-columns: 1fr 1fr 1fr; } } @media (max-width: 700px) { .wayfarer-status-effects-heading { flex-direction: column; } .wayfarer-status-effect { grid-template-columns: 1fr; } } ================================================================================ FILE: _update_backups/v100-20260930-191021/assets/js/status-subject-effects.js ================================================================================ /* WAYFARER_STATUS_SUBJECT_EFFECTS_UI_V89 */ (function () { 'use strict'; function randomKey(prefix) { const random = Math.random().toString(36).slice(2, 10); const time = Date.now().toString(36); return prefix + '_' + time + '_' + random; } const host = document.querySelector('[data-builder-status-lifecycle]'); if (!host || host.dataset.statusSubjectEffectsReady === '1') { return; } host.dataset.statusSubjectEffectsReady = '1'; const stagesList = host.querySelector('[data-status-stages-list]'); const effectTemplate = host.querySelector('[data-status-effect-template]'); const recurringEffectTemplate = host.querySelector('[data-status-recurring-effect-template]'); if (!stagesList || !effectTemplate || !recurringEffectTemplate) { return; } function stageRows() { return Array.from(stagesList.children).filter(function (node) { return node.matches && node.matches('[data-status-stage]'); }); } function outcomeRows(stage) { const list = stage.querySelector('[data-status-outcomes-list]'); return list ? Array.from(list.children).filter(function (node) { return node.matches && node.matches('[data-status-outcome]'); }) : []; } function effectRows(list) { return list ? Array.from(list.children).filter(function (node) { return node.matches && node.matches('[data-status-effect]'); }) : []; } function selectedTraitType(effect) { const target = effect.querySelector('[data-status-effect-target]'); const option = target && target.options[target.selectedIndex]; return option && option.dataset.statusTraitType === 'boolean' ? 'boolean' : 'number'; } function setControlVisible(container, visible) { if (!container) { return; } container.hidden = !visible; container.querySelectorAll('input, select, textarea').forEach(function (field) { field.disabled = !visible; }); } function refreshEffect(effect) { const dataType = selectedTraitType(effect); const operation = effect.querySelector('[data-status-effect-operation]'); const valueMode = effect.querySelector('[data-status-effect-value-mode]'); const modeWrap = effect.querySelector('[data-status-effect-value-mode-wrap]'); const fixedNumber = effect.querySelector('[data-status-effect-fixed-number]'); const fixedBoolean = effect.querySelector('[data-status-effect-fixed-boolean]'); const randomMin = effect.querySelector('[data-status-effect-random-min]'); const randomMax = effect.querySelector('[data-status-effect-random-max]'); if (operation) { Array.from(operation.options).forEach(function (option) { const numberOnly = option.hasAttribute('data-status-number-operation'); const allowed = dataType === 'number' || !numberOnly; option.disabled = !allowed; option.hidden = !allowed; }); const selected = operation.options[operation.selectedIndex]; if (dataType === 'boolean' && selected && selected.hasAttribute('data-status-number-operation')) { operation.value = 'set'; } } if (dataType === 'boolean') { if (valueMode) { valueMode.value = 'fixed'; } setControlVisible(modeWrap, false); setControlVisible(fixedNumber, false); setControlVisible(fixedBoolean, true); setControlVisible(randomMin, false); setControlVisible(randomMax, false); return; } setControlVisible(modeWrap, true); setControlVisible(fixedBoolean, false); const random = valueMode && valueMode.value === 'random'; setControlVisible(fixedNumber, !random); setControlVisible(randomMin, random); setControlVisible(randomMax, random); } function reindexEffectList(list, prefix) { effectRows(list).forEach(function (effect, effectIndex) { effect.querySelectorAll('[data-status-effect-field]').forEach(function (field) { const key = field.dataset.statusEffectField; if (key) { field.name = prefix + '[' + effectIndex + '][' + key + ']'; } }); refreshEffect(effect); }); } function reindexEffects() { stageRows().forEach(function (stage, stageIndex) { reindexEffectList( stage.querySelector('[data-status-entry-effects-list]'), 'status_lifecycle[stages][' + stageIndex + '][entry_effects]' ); reindexEffectList( stage.querySelector('[data-status-tick-effects-list]'), 'status_lifecycle[stages][' + stageIndex + '][tick_effects]' ); outcomeRows(stage).forEach(function (outcome, outcomeIndex) { reindexEffectList( outcome.querySelector('[data-status-outcome-effects-list]'), 'status_lifecycle[stages][' + stageIndex + '][outcomes][' + outcomeIndex + '][effects]' ); }); }); } function addEffect(list, recurring) { if (!list) { return; } const template = recurring ? recurringEffectTemplate : effectTemplate; const fragment = template.content.cloneNode(true); const effect = fragment.querySelector('[data-status-effect]'); if (!effect) { return; } const key = effect.querySelector('[data-status-effect-key]'); if (key) { key.value = randomKey('effect'); } list.appendChild(effect); reindexEffects(); } host.addEventListener('click', function (event) { const addEntry = event.target.closest('[data-add-status-entry-effect]'); if (addEntry) { const stage = addEntry.closest('[data-status-stage]'); if (stage) { addEffect(stage.querySelector('[data-status-entry-effects-list]'), false); } return; } const addTick = event.target.closest('[data-add-status-tick-effect]'); if (addTick) { const stage = addTick.closest('[data-status-stage]'); if (stage) { addEffect(stage.querySelector('[data-status-tick-effects-list]'), true); } return; } const addOutcome = event.target.closest('[data-add-status-outcome-effect]'); if (addOutcome) { const outcome = addOutcome.closest('[data-status-outcome]'); if (outcome) { addEffect(outcome.querySelector('[data-status-outcome-effects-list]'), false); } return; } const remove = event.target.closest('[data-remove-status-effect]'); if (remove) { const effect = remove.closest('[data-status-effect]'); if (effect && window.confirm('Remove this Status Effect?')) { effect.remove(); reindexEffects(); } return; } /* * V88 owns Stage/Outcome add, remove, and move actions. Its listener is * registered first, so this bubble-phase listener runs afterward and * refreshes Effect field names for the new Stage/Outcome ordering. */ if ( event.target.closest('[data-add-status-stage], [data-add-status-outcome], ' + '[data-remove-status-stage], [data-remove-status-outcome], ' + '[data-move-status-stage-up], [data-move-status-stage-down]') ) { window.setTimeout(reindexEffects, 0); } }); host.addEventListener('change', function (event) { const effect = event.target.closest('[data-status-effect]'); if ( effect && ( event.target.matches('[data-status-effect-target]') || event.target.matches('[data-status-effect-operation]') || event.target.matches('[data-status-effect-value-mode]') ) ) { refreshEffect(effect); } if (event.target.matches('[data-status-lifecycle-mode]')) { window.setTimeout(reindexEffects, 0); } }); reindexEffects(); })(); ================================================================================ FILE: _update_backups/v100-20260930-191021/includes/builder/status-lifecycle.php ================================================================================ Select root Storylet...'; foreach ($storylets as $storylet) { $id = (int)($storylet['id'] ?? 0); if ($id <= 0) { continue; } $name = trim((string)($storylet['name'] ?? ('Storylet ' . $id))); $status = strtolower(trim((string)($storylet['status'] ?? 'draft'))); $trigger = strtolower(trim((string)($storylet['trigger_type'] ?? 'manual'))); $suffix = []; if ($status !== 'active') { $suffix[] = ucfirst($status); } if ($trigger === 'event') { $suffix[] = 'Event'; } $label = $name; if ($suffix) { $label .= ' [' . implode(', ', $suffix) . ']'; } echo ''; } }; $statusLifecycleRenderResultOptions = static function ( array $stages, string $selected ): void { echo ''; echo ''; foreach ($stages as $stage) { if (!is_array($stage)) { continue; } $key = trim((string)($stage['key'] ?? '')); $name = trim((string)($stage['name'] ?? $key)); if ($key === '') { continue; } $value = 'stage:' . $key; echo ''; } }; /* WAYFARER_STATUS_OWNER_EFFECTS_EDITOR_V92 */ $statusLifecycleAppliesTo = strtolower( trim( (string)( $configValues['applies_to'] ?? 'character' ) ) ); if (!in_array($statusLifecycleAppliesTo, ['character', 'item', 'both'], true)) { $statusLifecycleAppliesTo = 'character'; } $statusLifecycleTraitCatalog = gameplayStatusLifecycleOwnerTraitCatalog( $pdo, (int)$gameId, $statusLifecycleAppliesTo ); $statusLifecycleRenderTraitOptions = static function ( array $catalog, string $selected = '' ): void { echo ''; foreach ($catalog as $targetId => $definition) { if (!is_array($definition)) { continue; } $name = trim((string)($definition['name'] ?? '')); $dataType = strtolower(trim((string)($definition['data_type'] ?? 'number'))); if ($name === '') { continue; } echo ''; } }; $statusLifecycleRenderEffectRow = static function ( array $effect, string $namePrefix, bool $recurring, array $traitCatalog, callable $renderTraitOptions ): void { $effectKey = trim((string)($effect['key'] ?? '')); $targetId = trim((string)($effect['target_id'] ?? '')); $operation = strtolower(trim((string)($effect['operation'] ?? 'set'))); $valueMode = strtolower(trim((string)($effect['value_mode'] ?? 'fixed'))); $value = $effect['value'] ?? 0; $valueMin = $effect['value_min'] ?? 0; $valueMax = $effect['value_max'] ?? 0; $interval = max(1, (int)($effect['interval_ticks'] ?? 1)); $dataType = isset($traitCatalog[$targetId]) ? (string)($traitCatalog[$targetId]['data_type'] ?? 'number') : strtolower(trim((string)($effect['data_type'] ?? 'number'))); ?>
>

Status Progression

Manual Statuses stay active until an Effect changes them. Timed Stages advance independently for every affected character. The first Stage is entered when the Status becomes active.

>

Stages

Each Stage rolls its duration once on entry. When its timer expires, exactly one Outcome is chosen from the authored percentages.
$stage): ?>
Stage
On Enter Effects Applied once when this character enters the Stage.
$effect): ?>
While in Stage Effects Applied to the Status Owner every authored number of gameplay ticks. The first interval starts when the Stage starts.
$effect): ?>
Outcomes Chances for this Stage must total exactly 100%.
$outcome): ?>
Outcome Effects Applied to the Status Owner before the Result transition and Storylet presentation.
$effect): ?>

When an Outcome opens a Storylet, Wayfarer supplies the affected character as the reserved subject binding. Use {{subject}} or {{subject.name}} in Storylet text. If the Storylet defines a normal Binding with Key subject, Status-triggered entry reuses the affected character instead of rolling a new one.

================================================================================ FILE: _update_backups/v101-20260930-193724/admin/builder.php ================================================================================ prepare(" SELECT id, name, config FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; /* WAYFARER_STORYLET_BUILDER_V56 * A + New Branch Storylet link can preselect its parent and choose a sensible * sibling order without making parent_storylet_id part of the generic request. */ if ( $type === 'storylets' && !$id && !empty($_GET['parent_storylet_id']) ) { $requestedParentId = (int)$_GET['parent_storylet_id']; $parentStmt = $pdo->prepare( " SELECT id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $parentStmt->execute([ $requestedParentId, $gameId, ]); if ($parentStmt->fetchColumn()) { $record['parent_storylet_id'] = $requestedParentId; $orderStmt = $pdo->prepare( " SELECT COALESCE(MAX(branch_sort_order), 0) FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? " ); $orderStmt->execute([ $gameId, $requestedParentId, ]); $record['branch_sort_order'] = (int)$orderStmt->fetchColumn() + 10; } } // ================================================================ // SAVE / DELETE / DUPLICATE // WAYFARER_STORYLET_DUPLICATE_V83 // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'duplicate') { $duplicateId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; if ( ($definition['table'] ?? '') !== 'wf_storylets' ) { $error = 'Only Storylets can be duplicated.'; } else { $duplicateResult = duplicateStoryletRecord( $pdo, $gameId, $duplicateId ); $error = $duplicateResult['error']; if ($duplicateResult['duplicated']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) . '&id=' . (int)$duplicateResult['id'] . '&tab=basics' . '&duplicated=1' ); exit; } } } elseif ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { /* * Media Library associations are applied only after the authored * object has saved successfully and therefore has a stable ID. */ $savedRecordId = (int)$saveResult['id']; $mediaResult = applyBuilderMediaSelections( $pdo, $gameId, $definition['table'], $savedRecordId, $_POST, $_FILES ); if (!$mediaResult['saved']) { /* * The authored object itself has already saved. Keep the * editor on that record and report the media failure * accurately rather than pretending the object save failed. */ $id = $savedRecordId; $record['id'] = $savedRecordId; $error = 'Record saved, but media was not changed: ' . $mediaResult['error']; } else { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) . '&id=' . (int)$saveResult['id'] . '&tab=' . rawurlencode( (string)($_POST['active_tab'] ?? 'basics') ) ); exit; } } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder

================================================================================ FILE: _update_backups/v101-20260930-193724/includes/builder/field.php ================================================================================

================================================================================ FILE: _update_backups/v102-20260930-201953/includes/builder/location-tick-effects.php ================================================================================ whether the Location is eligible to trigger. | Effects -> one-time state changes when it triggers. | | Conditions and Effects have separate builder tabs. | | Current target model: | - Game Traits | - Current Region Traits | - Navigation Position when the parent Region is linear | | Location Traits are not runtime targets yet because mutable per-Location | Trait state has not been introduced. | | Expected variables: | $pdo | $gameId | $game | $record | $id | */ require_once __DIR__ . '/../gameplay/functions.php'; $locationGameplayConfig = []; if ( !empty( $record['config'] ) ) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $locationGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted one-time Effect rows. * An absent effects collection during POST is canonical empty, which allows * removal of the final Effect. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' ) { $gameplayEffects = isset( $_POST['effects'] ) && is_array( $_POST['effects'] ) ? array_values( $_POST['effects'] ) : []; } else { $gameplayEffects = isset( $locationGameplayConfig['effects'] ) && is_array( $locationGameplayConfig['effects'] ) ? array_values( $locationGameplayConfig['effects'] ) : []; } /* * Failed POSTs retain the author's submitted Tick Effect rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' ) { $gameplayTickEffects = isset( $_POST['tick_effects'] ) && is_array( $_POST['tick_effects'] ) ? array_values( $_POST['tick_effects'] ) : []; } else { $gameplayTickEffects = isset( $locationGameplayConfig['tick_effects'] ) && is_array( $locationGameplayConfig['tick_effects'] ) ? array_values( $locationGameplayConfig['tick_effects'] ) : []; } /* * Game Trait targets. */ $gameConfig = []; if ( !empty( $game['config'] ) ) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * Parent Region context supplies Region Trait targets and determines whether * Navigation Position is valid for this Location. */ $parentRegionTraits = []; $parentRegionNavigationType = ''; $parentRegionId = (int)( $record['region_id'] ?? 0 ); if ($parentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $parentRegionId, $gameId, ]); $parentRegion = $regionStmt->fetch( PDO::FETCH_ASSOC ); if ($parentRegion) { $parentRegionNavigationType = (string)( $parentRegion['navigation_type'] ?? '' ); if ( !empty( $parentRegion['config'] ) ) { $decoded = json_decode( (string)$parentRegion['config'], true ); if ( is_array($decoded) && isset( $decoded['traits'] ) && is_array( $decoded['traits'] ) ) { $parentRegionTraits = $decoded['traits']; } } } } $gameplayScopeTraitTargets = gameplayNumericTraitTargets( $parentRegionTraits, 'region' ); $gameplayScopeKey = 'region'; $gameplayScopeLabel = 'Region'; $gameplayAllowNavigation = strtolower( trim( $parentRegionNavigationType ) ) === 'linear'; $gameplayNavigationType = $parentRegionNavigationType; /* WAYFARER_UNIVERSAL_LOCATION_TARGETS_V56 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; /* * WAYFARER_LOCATION_CONDITIONS_V45 * * Empty Conditions mean the Location is always eligible. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' ) { $locationGameplayConditions = $_POST['conditions'] ?? null; } else { $locationGameplayConditions = $locationGameplayConfig[ 'conditions' ] ?? null; } $gameplayEffectDescription = "These Effects run once when this Location is triggered."; $gameplayAllowEffectNavigation = false; /* * WAYFARER_LOCATION_EFFECTS_ONLY_V43 * * Locations are event boundaries. Recurring simulation belongs to Game and * Region Tick Effects; Location state changes belong to one-time Effects. */ ?>

Conditions

Optional. The Location triggers only when these conditions are true at the moment the player reaches or crosses its Position. If they are false, travel passes through the Location normally.

prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects, array $storyletBindings = [], int $currentStoryletId = 0 ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $allowedTargets = array_replace( gameplayRuntimeStateTargets( $pdo, $gameId ), gameplayStoryletBindingEffectTargets( $pdo, $gameId, $storyletBindings ), gameplayStoryletNavigationEffectTargets( $pdo, $gameId, $currentStoryletId ) ); $result = gameplayBuildEffectsFromPost( $allowedTargets, $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); if (($result['error'] ?? '') !== '') { return $result; } $transitionCount = 0; foreach ( $result['effects'] ?? [] as $effect ) { if ( is_array($effect) && (string)( $effect['target_scope'] ?? '' ) === 'storylet' && (string)( $effect['target_type'] ?? '' ) === 'transition' ) { $transitionCount++; } } if ($transitionCount > 1) { return [ 'effects' => [], 'error' => 'A Storylet may have only one Storylet → Continue To Effect.', ]; } return $result; } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } /* WAYFARER_SYSTEM_TRAIT_RANDOM_INITIAL_SAVE_V93 */ function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /* * WAYFARER_LINEAR_TRAVEL_SAVE_V72 * * Dedicated Linear Travel is deliberately opt-in. A null travel result means * the Region should continue using the legacy Position Tick Effect path. */ function buildRegionTravelFromPost( array $record, array $postedTravel ): array { $navigationType = strtolower( trim( (string)( $record['navigation_type'] ?? '' ) ) ); if ($navigationType !== 'linear') { return [ 'travel' => null, 'error' => '', ]; } if (empty($postedTravel['enabled'])) { return [ 'travel' => null, 'error' => '', ]; } $baseDistance = $postedTravel['base_distance'] ?? ''; if ( !is_numeric($baseDistance) || (float)$baseDistance <= 0 ) { return [ 'travel' => null, 'error' => 'Base Distance Per Tick must be greater than zero.', ]; } $baseDistance = (float)$baseDistance; if (floor($baseDistance) === $baseDistance) { $baseDistance = (int)$baseDistance; } return [ 'travel' => [ 'enabled' => true, 'base_distance' => $baseDistance, 'allow_turnaround' => !empty( $postedTravel[ 'allow_turnaround' ] ), ], 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Storylet duplication | WAYFARER_STORYLET_DUPLICATE_V83 |-------------------------------------------------------------------------- | | Duplicate the currently saved Storylet as a Draft. The duplicate copies | the selected Storylet record and its media links, but intentionally does | not recursively copy child Branch Storylets. | */ function duplicateStoryletRecord( PDO $pdo, int $gameId, int $storyletId ): array { if ($gameId <= 0 || $storyletId <= 0) { return [ 'duplicated' => false, 'id' => 0, 'error' => 'A valid Storylet is required to duplicate.', ]; } try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, uuid, game_id, name, storylet_key, description, conditions, effects, max_occurrences, trigger_type, status, config, parent_storylet_id, choice_label, choice_sort_order, branch_label, branch_sort_order FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $storyletId, $gameId, ]); $source = $stmt->fetch(PDO::FETCH_ASSOC); if (!$source) { throw new RuntimeException( 'The Storylet to duplicate could not be found.' ); } $sourceName = trim( (string)( $source['name'] ?? 'Storylet' ) ); if ($sourceName === '') { $sourceName = 'Storylet'; } $baseName = preg_replace( '/\s+-\s+COPY(?:\s+\d+)?$/i', '', $sourceName ); if (!is_string($baseName) || trim($baseName) === '') { $baseName = $sourceName; } $baseName = trim($baseName); /* Leave room for " - COPY 9999" within the 200-character Name. */ $baseName = function_exists('mb_substr') ? mb_substr($baseName, 0, 185, 'UTF-8') : substr($baseName, 0, 185); $copyName = $baseName . ' - COPY'; $copyNumber = 2; $nameCheck = $pdo->prepare( " SELECT 1 FROM wf_storylets WHERE game_id = ? AND name = ? LIMIT 1 " ); while (true) { $nameCheck->execute([ $gameId, $copyName, ]); if (!$nameCheck->fetchColumn()) { break; } $copyName = $baseName . ' - COPY ' . $copyNumber; $copyNumber++; if ($copyNumber > 9999) { throw new RuntimeException( 'Could not generate a unique Storylet copy name.' ); } } $sourceKey = trim( (string)( $source['storylet_key'] ?? '' ) ); if ($sourceKey === '') { $sourceKey = 'storylet-' . $storyletId; } $copyKey = substr( $sourceKey, 0, 80 ) . '-copy-' . bin2hex( random_bytes(6) ); $copyUuid = bin2hex( random_bytes(16) ); $parentId = (int)( $source['parent_storylet_id'] ?? 0 ); $branchSortOrder = (int)( $source['branch_sort_order'] ?? 0 ); $choiceSortOrder = (int)( $source['choice_sort_order'] ?? 0 ); $branchLabel = (string)( $source['branch_label'] ?? '' ); $choiceLabel = (string)( $source['choice_label'] ?? '' ); if ($parentId > 0) { $orderStmt = $pdo->prepare( " SELECT COALESCE(MAX(branch_sort_order), 0) FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? " ); $orderStmt->execute([ $gameId, $parentId, ]); $branchSortOrder = (int)$orderStmt->fetchColumn() + 10; $choiceSortOrder = $branchSortOrder; if (trim($branchLabel) !== '') { $branchLabel = rtrim($branchLabel) . ' - COPY'; } if (trim($choiceLabel) !== '') { $choiceLabel = rtrim($choiceLabel) . ' - COPY'; } } $insert = $pdo->prepare( " INSERT INTO wf_storylets ( uuid, game_id, name, storylet_key, description, conditions, effects, max_occurrences, trigger_type, status, config, parent_storylet_id, choice_label, choice_sort_order, branch_label, branch_sort_order ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?) " ); $insert->execute([ $copyUuid, $gameId, $copyName, $copyKey, $source['description'] ?? null, $source['conditions'] ?? null, $source['effects'] ?? null, $source['max_occurrences'] ?? null, $source['trigger_type'] ?? 'manual', $source['config'] ?? null, $parentId > 0 ? $parentId : null, $choiceLabel !== '' ? $choiceLabel : null, $choiceSortOrder, $branchLabel, $branchSortOrder, ]); $copyId = (int)$pdo->lastInsertId(); if ($copyId <= 0) { throw new RuntimeException( 'The Storylet copy could not be created.' ); } /* Reuse the same game-level media assets; do not duplicate files. */ $media = $pdo->prepare( " INSERT INTO wf_media_links ( media_id, object_type, object_id, slot ) SELECT media_id, object_type, ?, slot FROM wf_media_links WHERE object_type = 'wf_storylets' AND object_id = ? " ); $media->execute([ $copyId, $storyletId, ]); $pdo->commit(); return [ 'duplicated' => true, 'id' => $copyId, 'error' => '', ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } return [ 'duplicated' => false, 'id' => 0, 'error' => $exception->getMessage(), ]; } } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } $traitType = $traitDefinition['type'] ?? 'text'; $initialMode = 'fixed'; $randomInitialMin = null; $randomInitialMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_random_initial']) ) { $initialMode = strtolower( trim( (string)( $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['initial_mode'] ?? 'fixed' ) ) ); if ( !in_array( $initialMode, ['fixed', 'random'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ': invalid initial value mode.'; break; } if ($initialMode === 'random') { $postedInitialMin = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['initial_min'] ?? ''; $postedInitialMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['initial_max'] ?? ''; if (is_string($postedInitialMin)) { $postedInitialMin = trim($postedInitialMin); } if (is_string($postedInitialMax)) { $postedInitialMax = trim($postedInitialMax); } if ( $postedInitialMin === '' || !is_numeric($postedInitialMin) ) { $error = ($traitDefinition['label'] ?? $traitName) . ': random initial minimum must be a number.'; break; } if ( $postedInitialMax === '' || !is_numeric($postedInitialMax) ) { $error = ($traitDefinition['label'] ?? $traitName) . ': random initial maximum must be a number.'; break; } $randomInitialMin = (float)$postedInitialMin; $randomInitialMax = (float)$postedInitialMax; if ($randomInitialMin > $randomInitialMax) { $error = ($traitDefinition['label'] ?? $traitName) . ': random initial minimum cannot be greater than maximum.'; break; } if ( isset($traitDefinition['min']) && $randomInitialMin < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ': random initial minimum must be at least ' . $traitDefinition['min'] . '.'; break; } if ( isset($traitDefinition['max']) && $randomInitialMax > (float)$traitDefinition['max'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ': random initial maximum cannot exceed ' . $traitDefinition['max'] . '.'; break; } } } if ( !empty($traitDefinition['required']) && $value === '' && $initialMode !== 'random' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } if ( $traitType === 'number' && $initialMode !== 'random' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $initialMode !== 'random' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } $startingMaximum = $initialMode === 'random' ? $randomInitialMax : (float)$value; if ( $startingMaximum !== null && (float)$startingMaximum > (float)$postedMax ) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTrait = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $initialMode === 'random' ? 0 : $value, $authorMax ); if ( $traitType === 'number' && !empty($traitDefinition['allow_random_initial']) ) { $systemTrait['initial_mode'] = $initialMode; if ($initialMode === 'random') { $systemTrait['initial_min'] = $randomInitialMin; $systemTrait['initial_max'] = $randomInitialMax; } } $systemTraits[] = $systemTrait; } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_LINEAR_TRAVEL_SAVE_V72 */ $regionTravel = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionTravelResult = buildRegionTravelFromPost( $record, isset($post['travel']) && is_array($post['travel']) ? $post['travel'] : [] ); $regionTravel = $regionTravelResult['travel'] ?? null; if ( ($regionTravelResult['error'] ?? '') !== '' ) { $error = (string)$regionTravelResult['error']; } } /* WAYFARER_GAME_HUD_MOVED_SAVE_V94 * Region HUD authoring moved to Game Settings. Existing config.hud data is * deliberately left untouched here so V94 can read it as legacy fallback. */ /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STATUS_LIFECYCLE_SAVE_V88 */ /* WAYFARER_STATUS_OWNER_SAVE_V92 */ $statusLifecycle = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_statuses' ) { $statusLifecycleResult = gameplayBuildStatusLifecycleFromPost( $pdo, $gameId, $post['status_lifecycle'] ?? [], (string)($configValues['applies_to'] ?? 'character') ); $statusLifecycle = $statusLifecycleResult['lifecycle'] ?? null; $record['_status_lifecycle_editor'] = is_array( $statusLifecycleResult['editor'] ?? null ) ? $statusLifecycleResult['editor'] : []; if ( ($statusLifecycleResult['error'] ?? '') !== '' ) { $error = (string)$statusLifecycleResult['error']; } } /* WAYFARER_STORYLET_PARSE_V56 */ /* WAYFARER_STORYLET_BINDINGS_V79 */ $storyletBindings = []; $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $bindingResult = gameplayBuildStoryletBindingsFromPost( isset($post['bindings']) && is_array($post['bindings']) ? array_values($post['bindings']) : [] ); $storyletBindings = $bindingResult['bindings']; if ($bindingResult['error'] !== '') { $error = $bindingResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [], $storyletBindings, $postedId ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial_mode' => (string)( $systemTrait['initial_mode'] ?? 'fixed' ), 'initial' => $systemTrait['initial'] ?? 0, 'initial_min' => $systemTrait['initial_min'] ?? null, 'initial_max' => $systemTrait['initial_max'] ?? null, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_LINEAR_TRAVEL_SAVE_V72 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTravel !== null) { $configToSave['travel'] = $regionTravel; } else { unset( $configToSave['travel'] ); } } /* WAYFARER_GAME_HUD_MOVED_CONFIG_V94 * Preserve any legacy Region config.hud key already present in * $configToSave. New HUD definitions are saved at Game level. */ /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STATUS_LIFECYCLE_CONFIG_V88 */ if ( ($definition['table'] ?? '') === 'wf_statuses' ) { if (is_array($statusLifecycle)) { $configToSave['lifecycle'] = $statusLifecycle; } else { unset($configToSave['lifecycle']); } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { /* WAYFARER_STORYLET_BINDINGS_V79 */ if ($storyletBindings) { $configToSave['bindings'] = array_values($storyletBindings); } else { unset($configToSave['bindings']); } if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } /* * WAYFARER_STORYLET_INTERNAL_IDS_V91 * * uuid and storylet_key are structural Storylet identifiers, not * author-facing Builder fields. The generic INSERT therefore has to add * them explicitly for a new wf_storylets row. */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { $storyletUuid = bin2hex( random_bytes(16) ); $storyletKey = 'storylet-' . substr( $storyletUuid, 0, 24 ); $columns[] = '`uuid`'; $placeholders[] = '?'; $values[] = $storyletUuid; $columns[] = '`storylet_key`'; $placeholders[] = '?'; $values[] = $storyletKey; /* * Keep returned editor state coherent even though these values are * intentionally not displayed as authored fields. */ $record['uuid'] = $storyletUuid; $record['storylet_key'] = $storyletKey; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; /* * WAYFARER_STORYLET_UUID_SAVE_V90 * * Storylet UUID is an internal persistent identifier, not an authored * form value. Older/new-record builder state can therefore carry an * empty string here. Never send that empty value to the UNIQUE * wf_storylets.uuid column; create the identifier at insert time. * * Existing Storylet edits keep their stored UUID unchanged because * this branch runs only in the new-record INSERT path. */ if ( ($definition['table'] ?? '') === 'wf_storylets' && $fieldName === 'uuid' && trim((string)$value) === '' ) { $value = bin2hex( random_bytes(16) ); $record[$fieldName] = $value; } if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: _update_backups/v102-20260930-201953/play/includes/effects.php ================================================================================ true, 'value' => wayfarerNormalizeRuntimeNumber( (float)$effectValue ), 'error' => '', ]; } return [ 'applied' => true, 'value' => $effectValue, 'error' => '', ]; } if ( $operation !== 'add' && $operation !== 'subtract' ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Unsupported operation: ' . $operation, ]; } if ( !is_numeric($currentValue) || !is_numeric($effectValue) ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Add/subtract requires numeric values.', ]; } $current = (float)$currentValue; $amount = (float)$effectValue; $result = $operation === 'add' ? $current + $amount : $current - $amount; return [ 'applied' => true, 'value' => wayfarerNormalizeRuntimeNumber( $result ), 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Tick Effect branch runtime |-------------------------------------------------------------------------- | | A Tick Effect owns one target and an ordered list of result branches. | Conditions are evaluated against the CURRENT runtime state. Because Tick | Effects execute sequentially, a later Tick Effect can see state changes made | by an earlier Tick Effect during the same tick. | */ function wayfarerRuntimeResolveConditionTarget( array $runtimeState, int $currentRegionId, string $scope, string $targetType, string $targetId ): array { $scope = strtolower( trim( $scope ) ); $targetType = strtolower( trim( $targetType ) ); $targetId = trim( $targetId ); // ------------------------------------------------------------ // PARTY-MEMBER STATUS // WAYFARER_STATUS_CONDITION_RUNTIME_V87 // ------------------------------------------------------------ if ( $targetType === 'status' && preg_match('/^[1-9][0-9]*$/', $targetId) === 1 && preg_match( '/^(?:player:[0-9]+|entity:[1-9][0-9]*)$/', $scope ) === 1 ) { $statusState = wayfarerRuntimeStatusState( $runtimeState, $scope, (int)$targetId ); if (empty($statusState['found_owner'])) { return [ 'found' => false, 'value' => null, ]; } return [ 'found' => true, 'value' => !empty($statusState['active']) ? 1 : 0, ]; } // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $targetType === 'trait' && $targetId !== '' ) { $traits = $runtimeState['game']['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // SPECIFIC REGION TRAIT // WAYFARER_SPECIFIC_REGION_CONDITION_SCOPE_V46 // ------------------------------------------------------------ // // Game-level condition editors store a Region-owned Trait as // "region:". This lets a global rule inspect that Region whether // or not the player is currently there. // if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^region:([1-9][0-9]*)$/', $scope, $regionScopeMatch ) === 1 ) { $regionKey = (string)( (int)$regionScopeMatch[1] ); $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $targetType === 'trait' && $targetId !== '' && $currentRegionId > 0 ) { $regionKey = (string)$currentRegionId; $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // PLAYER / LOCATION / ENTITY TRAITS // WAYFARER_RUNTIME_UNIVERSAL_TARGET_RESOLVER_V56 // ------------------------------------------------------------ if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^player:([0-9]+)$/', $scope, $playerScopeMatch ) === 1 ) { $playerKey = (string)((int)$playerScopeMatch[1]); $traits = $runtimeState['players'][$playerKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^location:([1-9][0-9]*)$/', $scope, $locationScopeMatch ) === 1 ) { $locationKey = (string)((int)$locationScopeMatch[1]); $traits = $runtimeState['locations'][$locationKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityScopeMatch ) === 1 ) { $entityKey = (string)((int)$entityScopeMatch[1]); $traits = $runtimeState['entities'][$entityKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // GAME TIME // WAYFARER_HIERARCHICAL_TARGETS_V80 // ------------------------------------------------------------ if ( $scope === 'time' && $targetType === 'clock' ) { $clock = $runtimeState['runtime']['clock'] ?? null; if (!is_array($clock)) { return [ 'found' => false, 'value' => null, ]; } if ( $targetId === 'day' && array_key_exists( 'day', $clock ) ) { return [ 'found' => true, 'value' => (int)$clock['day'], ]; } if ( $targetId === 'hour' && array_key_exists( 'hour', $clock ) ) { return [ 'found' => true, 'value' => (int)$clock['hour'], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // REGION-QUALIFIED NAVIGATION POSITION // WAYFARER_LOCATION_CONDITION_TARGETS_V75 // ------------------------------------------------------------ if ( $targetType === 'position' && $targetId === 'position' && preg_match( '/^navigation:([1-9][0-9]*)$/', $scope, $navigationRegionMatch ) === 1 ) { $requiredRegionId = (int)$navigationRegionMatch[1]; if ( $requiredRegionId !== $currentRegionId ) { return [ 'found' => false, 'value' => null, ]; } if ( isset( $runtimeState['navigation'] ) && is_array( $runtimeState['navigation'] ) && array_key_exists( 'position', $runtimeState['navigation'] ) ) { return [ 'found' => true, 'value' => $runtimeState['navigation']['position'], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // LIVE LOCATION ANCHOR // WAYFARER_LIVE_LOCATION_ANCHORS_V76 // ------------------------------------------------------------ if ( $scope === 'navigation' && $targetType === 'location' && preg_match( '/^[1-9][0-9]*$/', $targetId ) === 1 ) { $locationKey = (string)((int)$targetId); $anchor = $runtimeState['locations'][$locationKey] ?? null; if (!is_array($anchor)) { return [ 'found' => false, 'value' => null, ]; } $anchorRegionId = (int)( $anchor['region_id'] ?? 0 ); $anchorPosition = $anchor['position'] ?? null; if ( $anchorRegionId <= 0 || !is_numeric($anchorPosition) ) { return [ 'found' => false, 'value' => null, ]; } /* * Before/After has no meaning across Regions. "Not at" is handled by * the Condition engine via comparison_scope_match=false. */ if ($anchorRegionId !== $currentRegionId) { return [ 'found' => true, 'value' => null, 'comparison_scope_match' => false, ]; } $currentPosition = $runtimeState['navigation']['position'] ?? null; if (!is_numeric($currentPosition)) { return [ 'found' => false, 'value' => null, ]; } return [ 'found' => true, 'value' => wayfarerNormalizeRuntimeNumber( (float)$currentPosition - (float)$anchorPosition ), 'comparison_scope_match' => true, ]; } // ------------------------------------------------------------ // NAMED LOCATION = CURRENT REGION + CURRENT NUMERIC POSITION // WAYFARER_LOCATION_CONDITION_TARGETS_V75 // ------------------------------------------------------------ if ( $scope === 'navigation' && $targetType === 'location' && $targetId === 'location' ) { if ( $currentRegionId <= 0 || !isset( $runtimeState['navigation'] ) || !is_array( $runtimeState['navigation'] ) || !array_key_exists( 'position', $runtimeState['navigation'] ) || !is_numeric( $runtimeState['navigation']['position'] ) ) { return [ 'found' => false, 'value' => null, ]; } $position = wayfarerNormalizeRuntimeNumber( (float)$runtimeState[ 'navigation' ]['position'] ); return [ 'found' => true, 'value' => $currentRegionId . ':' . (string)$position, ]; } // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $targetType === 'position' && $targetId === 'position' ) { if ( isset( $runtimeState['navigation'] ) && is_array( $runtimeState['navigation'] ) && array_key_exists( 'position', $runtimeState['navigation'] ) ) { return [ 'found' => true, 'value' => $runtimeState['navigation']['position'], ]; } return [ 'found' => false, 'value' => null, ]; } return [ 'found' => false, 'value' => null, ]; } function wayfarerRuntimeTickEffectBranches( array $effect ): array { /* * v28+ canonical structure. * * If the branches key exists, it is authoritative. An empty branches * array therefore means "this effect has no executable results" rather * than silently falling back to legacy compatibility fields. */ if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * Legacy pre-v28 structure. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'conditions', $effect ) ) { $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $effect['operation'] ?? '', 'value_mode' => $effect['value_mode'] ?? 'fixed', ]; if ( strtolower( trim( (string)$branch['value_mode'] ) ) === 'random' ) { $branch['value_min'] = $effect['value_min'] ?? 0; $branch['value_max'] = $effect['value_max'] ?? 0; } else { $branch['value'] = $effect['value'] ?? 0; } return [ $branch ]; } return []; } function wayfarerRuntimeSelectTickEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeTickEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Tick Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; /* * Validate before evaluating. Invalid authored data fails closed and * does NOT fall through to OTHERWISE, because doing so could silently * produce the wrong gameplay result. */ $validation = wayfarerConditionValidateNode( $conditions ); if (!$validation['valid']) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Tick Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Runtime Trait bounds |-------------------------------------------------------------------------- | | WAYFARER_RUNTIME_TRAIT_BOUNDS_V30 | | Authored numeric Trait min/max values are runtime invariants. | State is normalized before condition evaluation and every subsequent | mutation is clamped again before it is written. | */ function wayfarerRuntimeDecodeTraitConfig( $rawConfig ): array { if (is_array($rawConfig)) { return $rawConfig; } if ( $rawConfig === null || $rawConfig === '' ) { return []; } $decoded = json_decode( (string)$rawConfig, true ); return is_array($decoded) ? $decoded : []; } function wayfarerRuntimeIndexTraitDefinitions( array $traits ): array { $definitions = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $definitions[$traitId] = $trait; } return $definitions; } function wayfarerRuntimeLoadTickTraitDefinitions( PDO $pdo, int $gameId, array $regionConfig ): array { $gameConfig = []; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawGameConfig = $stmt->fetchColumn(); if ($rawGameConfig !== false) { $gameConfig = wayfarerRuntimeDecodeTraitConfig( $rawGameConfig ); } } /* WAYFARER_RUNTIME_UNIVERSAL_DEFINITIONS_V56 */ $definitions = [ 'game' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['traits'] ?? null ) ? $gameConfig['traits'] : [] ), 'player' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['player_traits'] ?? null ) ? $gameConfig['player_traits'] : [] ), 'region' => wayfarerRuntimeIndexTraitDefinitions( is_array( $regionConfig['traits'] ?? null ) ? $regionConfig['traits'] : [] ), 'regions' => [], 'locations' => [], 'entities' => [], ]; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $definitions['regions'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } $stmt = $pdo->prepare( " SELECT l.id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $definitions['locations'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } $stmt = $pdo->prepare( " SELECT id, entity_type, config FROM wf_entities WHERE game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); if ( function_exists( 'wayfarerEntityActiveTraitDefinitions' ) ) { $definitions['entities'][$id] = wayfarerEntityActiveTraitDefinitions( $config, (string)( $row['entity_type'] ?? '' ) ); } else { $definitions['entities'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } } } return $definitions; } function wayfarerRuntimeClampValueToTraitDefinition( $value, ?array $definition ): array { $result = [ 'valid' => true, 'value' => $value, 'clamped' => false, 'min' => null, 'max' => null, 'error' => '', ]; /* * Bounds currently belong only to authored Number Traits. */ if ( !$definition || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' ) { return $result; } if (!is_numeric($value)) { $result['valid'] = false; $result['error'] = 'Numeric Trait runtime state must be numeric.'; return $result; } $min = isset($definition['min']) && $definition['min'] !== '' && is_numeric($definition['min']) ? (float)$definition['min'] : null; $max = isset($definition['max']) && $definition['max'] !== '' && is_numeric($definition['max']) ? (float)$definition['max'] : null; $numeric = (float)$value; $bounded = $numeric; if ( $min !== null && $bounded < $min ) { $bounded = $min; } if ( $max !== null && $bounded > $max ) { $bounded = $max; } $result['value'] = wayfarerNormalizeRuntimeNumber( $bounded ); $result['clamped'] = $bounded !== $numeric; $result['min'] = $min === null ? null : wayfarerNormalizeRuntimeNumber( $min ); $result['max'] = $max === null ? null : wayfarerNormalizeRuntimeNumber( $max ); return $result; } function wayfarerRuntimeApplyBoundedTraitOperation( $storedBefore, string $operation, $effectValue, ?array $definition ): array { /* * Repair an already-illegal persisted value BEFORE applying the new * operation. Example: 120 with max 100 and subtract 10 becomes * 100 - 10 = 90, not 120 - 10 = 110 -> 100. */ $beforeBound = wayfarerRuntimeClampValueToTraitDefinition( $storedBefore, $definition ); if (!$beforeBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $storedBefore, 'requested_after' => $storedBefore, 'after' => $storedBefore, 'clamped' => false, 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $beforeBound['error'], ]; } $normalizedBefore = $beforeBound['value']; $operationResult = wayfarerApplyRuntimeOperation( $normalizedBefore, $operation, $effectValue ); if (!$operationResult['applied']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $normalizedBefore, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $operationResult['error'], ]; } $requestedAfter = $operationResult['value']; $afterBound = wayfarerRuntimeClampValueToTraitDefinition( $requestedAfter, $definition ); if (!$afterBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $afterBound['error'], ]; } return [ 'applied' => true, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $afterBound['value'], 'clamped' => $beforeBound['clamped'] || $afterBound['clamped'], 'min' => $afterBound['min'], 'max' => $afterBound['max'], 'error' => '', ]; } function wayfarerRuntimeClampApplicableTraitState( array &$runtimeState, array $traitDefinitions, int $currentRegionId ): array { $repairs = []; // ------------------------------------------------------------ // GAME TRAITS // ------------------------------------------------------------ $gameTraits = $runtimeState['game']['traits'] ?? null; if (is_array($gameTraits)) { foreach ( $traitDefinitions['game'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $gameTraits ) ) { continue; } $before = $runtimeState['game']['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['game']['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } // ------------------------------------------------------------ // CURRENT REGION TRAITS // ------------------------------------------------------------ $regionKey = (string)$currentRegionId; $regionTraits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if (is_array($regionTraits)) { foreach ( $traitDefinitions['region'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $regionTraits ) ) { continue; } $before = $runtimeState['regions'][$regionKey]['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } return $repairs; } function wayfarerApplyRuntimeTickEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectTickEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection['branch_index'], 'applied' => false, 'error' => $selection['error'], ]; } /* * No conditional branch matched and there was no OTHERWISE. * This is a valid "do nothing this tick" result. */ if (!$selection['matched']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection['branch_index']; $operation = strtolower( trim( (string)( $branch['operation'] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; /* * Explicit No Change: * the branch DID match, so this Tick Effect stops here, but state does * not mutate. */ if ($operation === 'none') { $current = wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $type, $targetId ); return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'amount' => null, 'before' => $current['found'] ? $current['value'] : null, 'after' => $current['found'] ? $current['value'] : null, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'applied' => false, 'no_change' => true, 'error' => '', ]; } /* WAYFARER_TICK_EFFECT_RANDOM_RUNTIME_V58 */ $resolvedValue = wayfarerRuntimeRollEffectValue( $branch ); if (!$resolvedValue['valid']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'applied' => false, 'error' => $resolvedValue['error'], ]; } /* * WAYFARER_BIDIRECTIONAL_TRAVEL_TICK_EFFECT_V71 * * Tick movement is relative to the player's current journey direction. * Only Tick Effects are reversed here. One-time narrative Effects remain * literal state changes, so an authored "Subtract 3 position" effect does * not unexpectedly change meaning because the player is traveling back. */ if ( $scope === 'navigation' && $type === 'position' && $targetId === 'position' && wayfarerRuntimeNavigationDirection( $runtimeState ) < 0 ) { if ($operation === 'add') { $operation = 'subtract'; } elseif ($operation === 'subtract') { $operation = 'add'; } } /* * Apply ONLY the selected result branch. The legacy effect-level * operation/value fields are deliberately ignored when branches exist. */ $resolvedEffect = $effect; $resolvedEffect['operation'] = $operation; $resolvedEffect['value'] = $resolvedValue['value']; $report = wayfarerApplyRuntimeEffect( $runtimeState, $resolvedEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; $report['value_mode'] = $resolvedValue['mode']; $report['rolled_value'] = $resolvedValue['value']; if ($resolvedValue['mode'] === 'random') { $report['random_min'] = $resolvedValue['min']; $report['random_max'] = $resolvedValue['max']; } return $report; } /* |-------------------------------------------------------------------------- | Party aggregate Effect helpers | WAYFARER_PARTY_TARGETS_RUNTIME_V85 |-------------------------------------------------------------------------- */ function wayfarerRuntimePartyNormalizeTraitName( string $value ): string { $value = trim( preg_replace( '/\\s+/u', ' ', $value ) ?? $value ); if (function_exists('mb_strtolower')) { return mb_strtolower( $value, 'UTF-8' ); } return strtolower($value); } function wayfarerRuntimePartyDecodeTraitTargetId( string $targetId ): ?string { $prefix = 'party_name_'; if ( strpos( $targetId, $prefix ) !== 0 ) { return null; } $hex = substr( $targetId, strlen($prefix) ); if ( $hex === '' || strlen($hex) % 2 !== 0 || preg_match( '/^[0-9a-f]+$/i', $hex ) !== 1 ) { return null; } $decoded = hex2bin($hex); return $decoded === false ? null : wayfarerRuntimePartyNormalizeTraitName( $decoded ); } function wayfarerRuntimePartyFindTraitDefinition( array $definitions, string $normalizedName ): ?array { $match = null; foreach ( $definitions as $definitionKey => $definition ) { if (!is_array($definition)) { continue; } $name = wayfarerRuntimePartyNormalizeTraitName( (string)( $definition['name'] ?? '' ) ); if ($name !== $normalizedName) { continue; } if ($match !== null) { return null; } $traitId = trim( (string)( $definition['_runtime_id'] ?? $definition['id'] ?? $definitionKey ) ); if ($traitId === '') { return null; } $match = [ 'id' => $traitId, 'definition' => $definition, ]; } return $match; } function wayfarerApplyRuntimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); $operation = strtolower( trim( (string)( $effect['operation'] ?? '' ) ) ); $effectValue = $effect['value'] ?? 0; // ------------------------------------------------------------ // PARTY-MEMBER STATUS // WAYFARER_STATUS_EFFECT_RUNTIME_V87 // ------------------------------------------------------------ if ( $type === 'status' && preg_match('/^[1-9][0-9]*$/', $targetId) === 1 ) { if ($operation !== 'set') { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Status Effects support Set only.', ]; } $active = wayfarerRuntimeStatusNormalizeBoolean( $effectValue ); if ($scope === 'party') { $results = []; $partyCount = 0; $firstError = ''; $players = isset($runtimeState['players']) && is_array($runtimeState['players']) ? $runtimeState['players'] : []; foreach ($players as $playerKey => $playerState) { if (!is_array($playerState)) { continue; } $name = trim( (string)( $playerState['name'] ?? '' ) ); if ($name === '') { continue; } $partyCount++; $memberScope = 'player:' . (string)$playerKey; $child = wayfarerApplyRuntimeEffect( $runtimeState, [ 'target_scope' => $memberScope, 'target_type' => 'status', 'target_id' => $targetId, 'operation' => 'set', 'value' => $active ? 1 : 0, ], $currentRegionId, $traitDefinitions ); $child['party_member_name'] = $name; $child['party_member_scope'] = $memberScope; $results[] = $child; if (!empty($child['error'])) { $firstError = (string)$child['error']; break; } } if ($firstError === '') { $entities = isset($runtimeState['entities']) && is_array($runtimeState['entities']) ? $runtimeState['entities'] : []; foreach ($entities as $entityKey => $entityState) { if (!is_array($entityState)) { continue; } $runtimeTraits = isset($entityState['traits']) && is_array($entityState['traits']) ? $entityState['traits'] : []; if (empty($runtimeTraits['sys_character_party_member'])) { continue; } $partyCount++; $memberScope = 'entity:' . (string)$entityKey; $child = wayfarerApplyRuntimeEffect( $runtimeState, [ 'target_scope' => $memberScope, 'target_type' => 'status', 'target_id' => $targetId, 'operation' => 'set', 'value' => $active ? 1 : 0, ], $currentRegionId, $traitDefinitions ); $child['party_member_scope'] = $memberScope; $results[] = $child; if (!empty($child['error'])) { $firstError = (string)$child['error']; break; } } } if ($firstError === '' && $partyCount === 0) { $firstError = 'There are no current party members.'; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $active ? 1 : 0, 'before' => null, 'after' => null, 'party_count' => $partyCount, 'eligible_count' => $partyCount, 'skipped_count' => 0, 'results' => $results, 'applied' => $firstError === '' && $partyCount > 0, 'error' => $firstError, ]; } if ( preg_match( '/^(?:player:[0-9]+|entity:[1-9][0-9]*)$/', $scope ) === 1 ) { $statusResult = wayfarerRuntimeSetStatus( $runtimeState, $scope, (int)$targetId, $active ); return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $active ? 1 : 0, 'before' => $statusResult['before'], 'after' => $statusResult['after'], 'started_tick' => $statusResult['started_tick'], 'applied' => $statusResult['applied'], 'error' => $statusResult['error'], ]; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Status Effect target scope is invalid.', ]; } // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $type === 'position' && $targetId === 'position' ) { if ( !isset($runtimeState['navigation']) || !is_array($runtimeState['navigation']) ) { $runtimeState['navigation'] = []; } $before = $runtimeState['navigation']['position'] ?? 0; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); $after = $before; if ( $result['applied'] && !is_numeric($result['value']) ) { $result['applied'] = false; $result['error'] = 'Navigation position must be numeric.'; } if ($result['applied']) { /* * Global navigation rule: * position may never be less than zero. */ $after = max( 0, $result['value'] ); $runtimeState['navigation']['position'] = $after; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $after, 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['game']) || !is_array($runtimeState['game']) ) { $runtimeState['game'] = []; } if ( !isset($runtimeState['game']['traits']) || !is_array($runtimeState['game']['traits']) ) { $runtimeState['game']['traits'] = []; } $before = $runtimeState['game']['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['game'][$targetId] ) && is_array( $traitDefinitions['game'][$targetId] ) ? $traitDefinitions['game'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); /* * Persist the normalized value even when a malformed operation fails * after an existing illegal value was repaired. */ if ( $result['normalized_before'] !== $before ) { $runtimeState['game']['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['game']['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // ALL PARTY MEMBERS - LOGICAL TRAIT BY NAME // WAYFARER_PARTY_TARGETS_RUNTIME_V85 // ------------------------------------------------------------ if ( $scope === 'party' && $type === 'trait' && $targetId !== '' ) { $normalizedName = wayfarerRuntimePartyDecodeTraitTargetId( $targetId ); if ($normalizedName === null) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Party Trait target is invalid.', ]; } $results = []; $eligibleCount = 0; $partyCount = 0; $firstError = ''; $players = isset($runtimeState['players']) && is_array($runtimeState['players']) ? $runtimeState['players'] : []; $playerDefinitions = isset($traitDefinitions['player']) && is_array($traitDefinitions['player']) ? $traitDefinitions['player'] : []; $playerTrait = wayfarerRuntimePartyFindTraitDefinition( $playerDefinitions, $normalizedName ); foreach ($players as $playerKey => $playerState) { if (!is_array($playerState)) { continue; } $name = trim( (string)( $playerState['name'] ?? '' ) ); if ($name === '') { continue; } $partyCount++; if (!is_array($playerTrait)) { continue; } $actualTargetId = (string)$playerTrait['id']; $runtimeTraits = isset($playerState['traits']) && is_array($playerState['traits']) ? $playerState['traits'] : []; if ( !array_key_exists( $actualTargetId, $runtimeTraits ) ) { continue; } $eligibleCount++; $memberScope = 'player:' . (string)$playerKey; $childEffect = [ 'target_scope' => $memberScope, 'target_type' => 'trait', 'target_id' => $actualTargetId, 'operation' => $operation, 'value' => $effectValue, ]; $child = wayfarerApplyRuntimeEffect( $runtimeState, $childEffect, $currentRegionId, $traitDefinitions ); $child['party_member_name'] = $name; $child['party_member_scope'] = $memberScope; $results[] = $child; if (!empty($child['error'])) { $firstError = (string)$child['error']; break; } } if ($firstError === '') { $entities = isset($runtimeState['entities']) && is_array($runtimeState['entities']) ? $runtimeState['entities'] : []; foreach ($entities as $entityKey => $entityState) { if (!is_array($entityState)) { continue; } $runtimeTraits = isset($entityState['traits']) && is_array($entityState['traits']) ? $entityState['traits'] : []; if ( empty( $runtimeTraits[ 'sys_character_party_member' ] ) ) { continue; } $partyCount++; $definitions = isset( $traitDefinitions['entities'][ (string)$entityKey ] ) && is_array( $traitDefinitions['entities'][ (string)$entityKey ] ) ? $traitDefinitions['entities'][ (string)$entityKey ] : []; $entityTrait = wayfarerRuntimePartyFindTraitDefinition( $definitions, $normalizedName ); if (!is_array($entityTrait)) { continue; } $actualTargetId = (string)$entityTrait['id']; if ( !array_key_exists( $actualTargetId, $runtimeTraits ) ) { continue; } $eligibleCount++; $memberScope = 'entity:' . (string)$entityKey; $childEffect = [ 'target_scope' => $memberScope, 'target_type' => 'trait', 'target_id' => $actualTargetId, 'operation' => $operation, 'value' => $effectValue, ]; $child = wayfarerApplyRuntimeEffect( $runtimeState, $childEffect, $currentRegionId, $traitDefinitions ); $child['party_member_scope'] = $memberScope; $results[] = $child; if (!empty($child['error'])) { $firstError = (string)$child['error']; break; } } } if ( $firstError === '' && $eligibleCount === 0 ) { $firstError = 'No current party member exposes the selected Trait.'; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'party_count' => $partyCount, 'eligible_count' => $eligibleCount, 'skipped_count' => max( 0, $partyCount - $eligibleCount ), 'results' => $results, 'applied' => $firstError === '' && $eligibleCount > 0, 'error' => $firstError, ]; } // ------------------------------------------------------------ // ITEM ENTITY QUANTITY // WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 // ------------------------------------------------------------ if ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityScopeMatch ) === 1 && ( (string)( $effect['target_system_key'] ?? '' ) === 'quantity' || $targetId === 'sys_item_quantity' ) ) { $entityKey = (string)( (int)$entityScopeMatch[1] ); if ( !isset( $runtimeState['entities'][$entityKey] ) || !is_array( $runtimeState['entities'][$entityKey] ) || !isset( $runtimeState['entities'][$entityKey]['traits'] ) || !is_array( $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Entity runtime state was not initialized.', ]; } if ( !array_key_exists( $targetId, $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Quantity runtime state was not found.', ]; } $before = $runtimeState['entities'][$entityKey]['traits'][$targetId]; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); if ( !$result['applied'] || !is_numeric( $result['value'] ?? null ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $before, 'applied' => false, 'error' => $result['error'] !== '' ? $result['error'] : 'Item Quantity must remain numeric.', ]; } $requestedAfter = wayfarerNormalizeRuntimeNumber( (float)$result['value'] ); /* * Quantity is globally non-negative. */ $after = wayfarerNormalizeRuntimeNumber( max( 0.0, (float)$requestedAfter ) ); $runtimeState['entities'][$entityKey]['traits'][$targetId] = $after; /* * Discovery is persistent. Receiving any positive starting quantity * discovers the Item; setting it back to zero never undiscoveres it. */ if ( (float)$after > 0 ) { $runtimeState['entities'][$entityKey]['discovered'] = true; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_system_key' => 'quantity', 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'requested_after' => $requestedAfter, 'after' => $after, 'clamped' => (float)$after !== (float)$requestedAfter, 'min' => 0, 'max' => null, 'applied' => true, 'error' => '', ]; } // ------------------------------------------------------------ // SPECIFIC PLAYER / REGION / LOCATION / ENTITY TRAITS // WAYFARER_RUNTIME_UNIVERSAL_EFFECT_TARGETS_V56 // ------------------------------------------------------------ $stateBucket = null; $stateKey = null; $definition = null; if ( $type === 'trait' && $targetId !== '' && preg_match( '/^player:([0-9]+)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'players'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['player'][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^region:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'regions'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['regions'][$stateKey][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^location:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'locations'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['locations'][$stateKey][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 && $targetId !== 'sys_item_quantity' ) { $stateBucket = 'entities'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['entities'][$stateKey][$targetId] ?? null; } if ( $stateBucket !== null && $stateKey !== null ) { if ( !isset($runtimeState[$stateBucket][$stateKey]) || !is_array($runtimeState[$stateBucket][$stateKey]) || !isset($runtimeState[$stateBucket][$stateKey]['traits']) || !is_array($runtimeState[$stateBucket][$stateKey]['traits']) || !array_key_exists( $targetId, $runtimeState[$stateBucket][$stateKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Runtime Trait target was not found.', ]; } $before = $runtimeState[$stateBucket][$stateKey]['traits'][$targetId]; /* * Party Membership is mutable runtime state, but still a binary * Wayfarer system property. The 0/1 rule is enforced internally and is * never exposed as a separate Target/max metadata value. */ if ( $stateBucket === 'entities' && $targetId === 'sys_character_party_member' ) { $operationResult = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); if ( !$operationResult['applied'] || !is_numeric( $operationResult['value'] ?? null ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $before, 'applied' => false, 'error' => $operationResult['error'] !== '' ? $operationResult['error'] : 'Party Membership requires a numeric 0/1 result.', ]; } $after = (float)$operationResult['value'] > 0 ? 1 : 0; $runtimeState[$stateBucket][$stateKey]['traits'][$targetId] = $after; return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_system_key' => 'party_member', 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $after, 'applied' => true, 'error' => '', ]; } $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, is_array($definition) ? $definition : null ); if ($result['applied']) { $runtimeState[$stateBucket][$stateKey]['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // PLAYER TRAIT // WAYFARER_STARTING_CHOICE_PLAYER_TARGETS_V53 // ------------------------------------------------------------ if ( $scope === 'player' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['players']) || !is_array($runtimeState['players']) ) { $runtimeState['players'] = []; } $selector = strtolower( trim( (string)( $effect['target_selector'] ?? '' ) ) ); $targetKeys = []; if ($selector === 'all') { foreach ( $runtimeState['players'] as $playerKey => $playerState ) { if (is_array($playerState)) { $targetKeys[] = (string)$playerKey; } } } elseif ( $selector === 'slot' && array_key_exists( 'target_slot', $effect ) ) { $slot = max( 0, (int)$effect['target_slot'] ); $targetKeys[] = (string)$slot; } else { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect requires an All Players or Player Slot target.', ]; } if (empty($targetKeys)) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect did not resolve any Player Characters.', ]; } $definition = isset( $traitDefinitions['player'][$targetId] ) && is_array( $traitDefinitions['player'][$targetId] ) ? $traitDefinitions['player'][$targetId] : null; $results = []; $firstError = ''; foreach ($targetKeys as $playerKey) { if ( !isset( $runtimeState['players'][$playerKey] ) || !is_array( $runtimeState['players'][$playerKey] ) ) { $firstError = 'Player Character slot ' . ((int)$playerKey + 1) . ' does not exist in runtime state.'; $results[$playerKey] = [ 'applied' => false, 'error' => $firstError, ]; break; } if ( !isset( $runtimeState['players'][$playerKey]['traits'] ) || !is_array( $runtimeState['players'][$playerKey]['traits'] ) ) { $runtimeState['players'][$playerKey]['traits'] = []; } $before = $runtimeState['players'][$playerKey]['traits'][$targetId] ?? 0; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['after']; } $results[$playerKey] = [ 'slot' => (int)$playerKey, 'name' => (string)( $runtimeState['players'][$playerKey]['name'] ?? ( 'Player ' . ((int)$playerKey + 1) ) ), 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; if ( !$result['applied'] || $result['error'] !== '' ) { $firstError = $result['error'] !== '' ? $result['error'] : 'Player Trait effect could not be applied.'; break; } } $allApplied = $firstError === ''; return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_selector' => $selector, 'target_slot' => $selector === 'slot' ? (int)( $effect['target_slot'] ?? 0 ) : null, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'results' => $results, 'applied' => $allApplied, 'error' => $firstError, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $type === 'trait' && $targetId !== '' ) { $regionKey = (string)$currentRegionId; if ( !isset($runtimeState['regions']) || !is_array($runtimeState['regions']) ) { $runtimeState['regions'] = []; } if ( !isset($runtimeState['regions'][$regionKey]) || !is_array($runtimeState['regions'][$regionKey]) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset($runtimeState['regions'][$regionKey]['traits']) || !is_array($runtimeState['regions'][$regionKey]['traits']) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } $before = $runtimeState['regions'][$regionKey]['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['region'][$targetId] ) && is_array( $traitDefinitions['region'][$targetId] ) ? $traitDefinitions['region'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Unsupported runtime target.', ]; } /* |-------------------------------------------------------------------------- | Game Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_GAME_TICK_EFFECT_RUNTIME_V33 | | Game Tick Effects execute once on every successful runtime tick before | the active Region's Tick Effects. This creates a deterministic layering: | | Game rules -> Region rules | */ function wayfarerApplyGameTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { if ($gameId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active game is available for Game Tick Effects.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { $reports = [ [ 'applied' => false, 'error' => 'Game ' . $gameId . ' was not found.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $gameConfig = []; if ( $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, [] ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $gameEffects = $gameConfig['tick_effects'] ?? []; if (!is_array($gameEffects)) { $gameEffects = []; } $reports = []; foreach ($gameEffects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Game Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; $runtimeState['runtime']['last_game_trait_bound_repairs'] = $boundRepairs; return $reports; } /* |-------------------------------------------------------------------------- | Generalized one-time Effect runtime | WAYFARER_GENERALIZED_EFFECT_RUNTIME_V43 |-------------------------------------------------------------------------- | | Conditions use the same shared evaluator as Tick Effect branches. | Random values are resolved exactly once, immediately before the selected | result mutates runtime state. | */ function wayfarerRuntimeEffectBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * v42 compatibility: one Effect was one unconditional operation/value. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $branch = [ 'conditions' => null, 'operation' => $effect['operation'] ?? 'add', 'value_mode' => $effect['value_mode'] ?? 'fixed', ]; if ( strtolower( trim( (string)( $branch[ 'value_mode' ] ) ) ) === 'random' ) { $branch['value_min'] = $effect['value_min'] ?? 0; $branch['value_max'] = $effect['value_max'] ?? 0; } else { $branch['value'] = $effect['value'] ?? 0; } return [ $branch ]; } return []; } function wayfarerRuntimeSelectEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; $validation = wayfarerConditionValidateNode( $conditions ); if ( !$validation['valid'] ) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } function wayfarerRuntimeRollEffectValue( array $branch ): array { $mode = strtolower( trim( (string)( $branch[ 'value_mode' ] ?? 'fixed' ) ) ); if ($mode !== 'random') { $value = $branch['value'] ?? null; /* WAYFARER_FIXED_SCALAR_EFFECT_RUNTIME_V58 */ return [ 'valid' => true, 'mode' => 'fixed', 'value' => is_numeric($value) ? wayfarerNormalizeRuntimeNumber( (float)$value ) : $value, 'min' => null, 'max' => null, 'error' => '', ]; } $minimum = $branch['value_min'] ?? null; $maximum = $branch['value_max'] ?? null; if ( !is_numeric($minimum) || !is_numeric($maximum) ) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => $minimum, 'max' => $maximum, 'error' => 'Random Effect requires numeric Min and Max values.', ]; } $min = (float)$minimum; $max = (float)$maximum; if ($min > $max) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => 'Random Effect Min cannot be greater than Max.', ]; } if ( floor($min) === $min && floor($max) === $max ) { $rolled = random_int( (int)$min, (int)$max ); } else { /* * Continuous decimal range. Six decimal places of entropy are more * than sufficient for authored simulation values and preserve generic * numeric Min/Max support without requiring authored precision metadata. */ $fraction = random_int( 0, 1000000 ) / 1000000; $rolled = $min + (($max - $min) * $fraction); } return [ 'valid' => true, 'mode' => 'random', 'value' => wayfarerNormalizeRuntimeNumber( (float)$rolled ), 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => '', ]; } function wayfarerApplyRuntimeOneTimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect[ 'target_scope' ] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect[ 'target_type' ] ?? '' ) ) ); $targetId = trim( (string)( $effect[ 'target_id' ] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection[ 'branch_index' ], 'applied' => false, 'error' => $selection[ 'error' ], ]; } if ( !$selection['matched'] ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection[ 'branch_index' ]; $operation = strtolower( trim( (string)( $branch[ 'operation' ] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; if ($operation === 'none') { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => null, 'rolled_value' => null, 'applied' => false, 'error' => '', ]; } $resolved = wayfarerRuntimeRollEffectValue( $branch ); if (!$resolved['valid']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => $resolved[ 'mode' ], 'rolled_value' => null, 'random_min' => $resolved[ 'min' ], 'random_max' => $resolved[ 'max' ], 'applied' => false, 'error' => $resolved[ 'error' ], ]; } $concreteEffect = [ 'target_scope' => $scope, 'target_type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'value' => $resolved[ 'value' ], ]; $report = wayfarerApplyRuntimeEffect( $runtimeState, $concreteEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; $report['value_mode'] = $resolved[ 'mode' ]; $report['rolled_value'] = $resolved[ 'value' ]; if ( $resolved['mode'] === 'random' ) { $report['random_min'] = $resolved[ 'min' ]; $report['random_max'] = $resolved[ 'max' ]; } return $report; } /* |-------------------------------------------------------------------------- | One-time Effect player feedback | WAYFARER_ONE_TIME_EFFECT_FEEDBACK_V70 |-------------------------------------------------------------------------- | | Change events are derived from the Effect report AFTER runtime mutation. | Therefore Random rolls and min/max/non-negative clamps are already reflected | in before/after, and the player sees the quantity that truly changed. | | This layer is intentionally called only by narrative one-time Effect paths. | Tick Effects remain silent. | */ function wayfarerRuntimeFeedbackFindTrait( array $config, string $targetId, string $collection = 'traits' ): ?array { $traits = isset($config[$collection]) && is_array($config[$collection]) ? $config[$collection] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } $id = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($id === $targetId) { return $trait; } } return null; } function wayfarerRuntimeFeedbackTraitIsPlayerFacing(array $trait): bool { $feedback = strtolower( trim( (string)( $trait['feedback'] ?? '' ) ) ); if ($feedback === 'hide') { return false; } if ($feedback === 'show') { return true; } $display = isset($trait['display']) && is_array($trait['display']) ? $trait['display'] : []; $surfaces = isset($display['surfaces']) && is_array($display['surfaces']) ? $display['surfaces'] : []; return !empty($surfaces); } function wayfarerRuntimeFeedbackTraitMetadata( array $trait, string $fallbackLabel, string $ownerLabel = '' ): ?array { if (!wayfarerRuntimeFeedbackTraitIsPlayerFacing($trait)) { return null; } $label = trim( (string)( $trait['name'] ?? $fallbackLabel ) ); if ($label === '') { $label = $fallbackLabel; } $type = strtolower( trim( (string)( $trait['type'] ?? 'number' ) ) ); if ($type === 'binary') { $type = 'boolean'; } return [ 'kind' => 'trait', 'label' => $label, 'owner_label' => $ownerLabel, 'data_type' => $type, ]; } function wayfarerRuntimeFeedbackTargetMetadata( PDO $pdo, int $gameId, array $runtimeState, array $report ): ?array { $scope = strtolower( trim( (string)( $report['scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $report['type'] ?? '' ) ) ); $targetId = trim( (string)( $report['target_id'] ?? '' ) ); if ( $scope === '' || $targetId === '' || !in_array($type, ['trait', 'status'], true) ) { return null; } /* WAYFARER_STATUS_EFFECT_FEEDBACK_V87 */ if ( $type === 'status' && preg_match('/^[1-9][0-9]*$/', $targetId) === 1 ) { $definition = wayfarerRuntimeStatusDefinition( $pdo, $gameId, (int)$targetId, true ); if ( !is_array($definition) || empty($definition['show_to_player']) ) { return null; } $ownerLabel = ''; if ( preg_match( '/^player:([0-9]+)$/', $scope, $playerStatusMatch ) === 1 ) { $slot = (string)((int)$playerStatusMatch[1]); $ownerLabel = trim( (string)( $runtimeState['players'][$slot]['name'] ?? ('Player ' . ((int)$slot + 1)) ) ); } elseif ( preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityStatusMatch ) === 1 ) { $entityId = (int)$entityStatusMatch[1]; $stmt = $pdo->prepare( " SELECT name FROM wf_entities WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $entityId, $gameId, ]); $name = $stmt->fetchColumn(); if ($name !== false) { $ownerLabel = trim((string)$name); } } return [ 'kind' => 'status', 'label' => (string)($definition['name'] ?? ('Status ' . $targetId)), 'owner_label' => $ownerLabel, 'data_type' => 'boolean', ]; } /* Navigation is deliberately silent even for one-time Effects. */ if ($scope === 'navigation') { return null; } /* -------------------------------------------------------------- * Entity state: Quantity and Party Membership are intrinsically visible. * ----------------------------------------------------------- */ if ( preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityMatch ) === 1 ) { $entityId = (int)$entityMatch[1]; $stmt = $pdo->prepare( " SELECT name, entity_type, config FROM wf_entities WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $entityId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $entityName = trim( (string)( $row['name'] ?? ('Entity ' . $entityId) ) ); if ($entityName === '') { $entityName = 'Entity ' . $entityId; } $systemKey = strtolower( trim( (string)( $report['target_system_key'] ?? '' ) ) ); if ( $targetId === 'sys_item_quantity' || $systemKey === 'quantity' ) { return [ 'kind' => 'item_quantity', 'label' => $entityName, 'owner_label' => $entityName, 'data_type' => 'number', ]; } if ( $targetId === 'sys_character_party_member' || $systemKey === 'party_member' ) { return [ 'kind' => 'party_membership', 'label' => $entityName, 'owner_label' => $entityName, 'data_type' => 'boolean', ]; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, $entityName ) : null; } /* -------------------------------------------------------------- * Game Trait. * ----------------------------------------------------------- */ if ($scope === 'game') { $stmt = $pdo->prepare( 'SELECT config FROM wf_games WHERE id = ? LIMIT 1' ); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); if ($raw === false) { return null; } $config = wayfarerRuntimeDecodeTraitConfig($raw); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, 'Game' ) : null; } /* -------------------------------------------------------------- * Specific Region Trait, plus legacy current-Region compatibility. * ----------------------------------------------------------- */ $regionId = 0; if ( preg_match( '/^region:([1-9][0-9]*)$/', $scope, $regionMatch ) === 1 ) { $regionId = (int)$regionMatch[1]; } elseif ($scope === 'region') { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); } if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, trim((string)($row['name'] ?? 'Region')) ) : null; } /* -------------------------------------------------------------- * Location Trait. * ----------------------------------------------------------- */ if ( preg_match( '/^location:([1-9][0-9]*)$/', $scope, $locationMatch ) === 1 ) { $locationId = (int)$locationMatch[1]; $stmt = $pdo->prepare( " SELECT l.name, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE l.id = ? AND r.game_id = ? LIMIT 1 " ); $stmt->execute([ $locationId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, trim((string)($row['name'] ?? 'Location')) ) : null; } /* -------------------------------------------------------------- * Specific Player Character Trait. * ----------------------------------------------------------- */ if ( preg_match( '/^player:([0-9]+)$/', $scope, $playerMatch ) === 1 ) { $slot = (int)$playerMatch[1]; $stmt = $pdo->prepare( 'SELECT config FROM wf_games WHERE id = ? LIMIT 1' ); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); if ($raw === false) { return null; } $config = wayfarerRuntimeDecodeTraitConfig($raw); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId, 'player_traits' ); if (!is_array($trait)) { return null; } $playerName = trim( (string)( $runtimeState['players'][(string)$slot]['name'] ?? ('Player ' . ($slot + 1)) ) ); return wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, $playerName ); } return null; } function wayfarerRuntimeBuildEffectChangeEvents( PDO $pdo, int $gameId, array $runtimeState, array $reports ): array { $changes = []; foreach ($reports as $report) { if ( !is_array($report) || empty($report['applied']) ) { continue; } /* WAYFARER_PARTY_TARGETS_RUNTIME_V85 */ if ( strtolower( trim( (string)( $report['scope'] ?? '' ) ) ) === 'party' && isset($report['results']) && is_array($report['results']) ) { foreach ( wayfarerRuntimeBuildEffectChangeEvents( $pdo, $gameId, $runtimeState, $report['results'] ) as $partyChange ) { $changes[] = $partyChange; } continue; } $metadata = wayfarerRuntimeFeedbackTargetMetadata( $pdo, $gameId, $runtimeState, $report ); if (!is_array($metadata)) { continue; } $before = $report['before'] ?? null; $after = $report['after'] ?? null; $kind = (string)($metadata['kind'] ?? 'trait'); $label = trim((string)($metadata['label'] ?? '')); $ownerLabel = trim((string)($metadata['owner_label'] ?? '')); if ($label === '') { continue; } if ($kind === 'status') { if (!is_numeric($before) || !is_numeric($after)) { continue; } $beforeActive = (float)$before > 0; $afterActive = (float)$after > 0; if ($beforeActive === $afterActive) { continue; } $displayLabel = $ownerLabel !== '' ? $label . ' — ' . $ownerLabel : $label; $changes[] = [ 'kind' => 'status', 'direction' => $afterActive ? 'gain' : 'loss', 'label' => $label, 'owner_label' => $ownerLabel, 'before' => $beforeActive ? 1 : 0, 'after' => $afterActive ? 1 : 0, 'delta' => $afterActive ? 1 : -1, 'text' => $displayLabel . ': ' . ($afterActive ? 'Active' : 'Inactive'), 'aria_text' => ($ownerLabel !== '' ? $ownerLabel . ': ' . $label : $label) . ' is now ' . ($afterActive ? 'active.' : 'inactive.'), ]; continue; } if ($kind === 'party_membership') { if (!is_numeric($before) || !is_numeric($after)) { continue; } $beforeMember = (float)$before > 0; $afterMember = (float)$after > 0; if ($beforeMember === $afterMember) { continue; } $joined = $afterMember; $changes[] = [ 'kind' => 'party_membership', 'direction' => $joined ? 'gain' : 'loss', 'label' => $label, 'before' => $beforeMember ? 1 : 0, 'after' => $afterMember ? 1 : 0, 'delta' => $joined ? 1 : -1, 'text' => $label . ($joined ? ' joined your party.' : ' left your party.'), 'aria_text' => $label . ($joined ? ' joined your party.' : ' left your party.'), ]; continue; } $dataType = strtolower( trim((string)($metadata['data_type'] ?? 'number')) ); if ($dataType === 'boolean') { if (!is_numeric($before) || !is_numeric($after)) { continue; } $beforeBool = (float)$before > 0; $afterBool = (float)$after > 0; if ($beforeBool === $afterBool) { continue; } $displayLabel = $ownerLabel !== '' && $ownerLabel !== $label ? $label . ' — ' . $ownerLabel : $label; $changes[] = [ 'kind' => $kind, 'direction' => $afterBool ? 'gain' : 'loss', 'label' => $label, 'owner_label' => $ownerLabel, 'before' => $beforeBool ? 1 : 0, 'after' => $afterBool ? 1 : 0, 'delta' => $afterBool ? 1 : -1, 'text' => $displayLabel . ': ' . ($afterBool ? 'On' : 'Off'), 'aria_text' => ($ownerLabel !== '' && $ownerLabel !== $label ? $ownerLabel . ': ' . $label : $label) . ' is now ' . ($afterBool ? 'on.' : 'off.'), ]; continue; } if (!is_numeric($before) || !is_numeric($after)) { continue; } $deltaFloat = (float)$after - (float)$before; if (abs($deltaFloat) < 0.000000000001) { continue; } $delta = wayfarerNormalizeRuntimeNumber($deltaFloat); $amount = wayfarerNormalizeRuntimeNumber(abs($deltaFloat)); $gain = $deltaFloat > 0; $displayLabel = $ownerLabel !== '' && $ownerLabel !== $label && $kind !== 'item_quantity' ? $label . ' — ' . $ownerLabel : $label; $text = ($gain ? '+' : '-') . (string)$amount . ' ' . $displayLabel; if ($kind === 'item_quantity') { $ariaText = ($gain ? 'Gained ' : 'Lost ') . (string)$amount . ' ' . $label . '.'; } else { $ariaSubject = $ownerLabel !== '' && $ownerLabel !== $label ? $ownerLabel . ': ' . $label : $label; $ariaText = $ariaSubject . ($gain ? ' increased by ' : ' decreased by ') . (string)$amount . '.'; } $changes[] = [ 'kind' => $kind, 'direction' => $gain ? 'gain' : 'loss', 'label' => $label, 'owner_label' => $ownerLabel, 'before' => $before, 'after' => $after, 'delta' => $delta, 'text' => $text, 'aria_text' => $ariaText, ]; } return $changes; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECT_RUNTIME_V42 | | These are event Effects, not Tick Effects. The caller decides that a | Location has been triggered and invokes this function exactly once for that | trigger. Effects execute top-to-bottom against current runtime state. | */ function wayfarerApplyLocationEffects( PDO $pdo, int $gameId, array $location, array &$runtimeState ): array { $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeTraitConfig( $location['config'] ?? null ); $effects = $locationConfig['effects'] ?? []; if (!is_array($effects)) { $effects = []; } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false ) { $regionConfig = wayfarerRuntimeDecodeTraitConfig( $rawRegionConfig ); } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeOneTimeEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_location_effects'] = [ 'location_id' => $locationId, 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), 'reports' => $reports, /* WAYFARER_LOCATION_EFFECT_FEEDBACK_V70 */ 'changes' => wayfarerRuntimeBuildEffectChangeEvents( $pdo, $gameId, $runtimeState, $reports ), 'bound_repairs' => $boundRepairs, ]; return $reports; } /* |-------------------------------------------------------------------------- | Current Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECT_RUNTIME_V35 | | Linear Regions resolve a current Location by exact authored Position, | matching the player-scene resolver. Location Tick Effects run after Region | Tick Effects, so movement performed by the Region can cause arrival effects | at the newly reached Location on the same tick. | */ function wayfarerRuntimeFindCurrentLocation( PDO $pdo, int $gameId, array $runtimeState ): ?array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ( $gameId <= 0 || $regionId <= 0 ) { return null; } $position = $runtimeState['navigation']['position'] ?? null; if (!is_numeric($position)) { return null; } $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($locations as $location) { $config = []; if ( !empty( $location['config'] ) ) { $decoded = json_decode( (string)$location['config'], true ); if (is_array($decoded)) { $config = $decoded; } } if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } if ( (float)$config['position'] === (float)$position ) { $location['_runtime_config'] = $config; /* * WAYFARER_LOCATION_CONDITIONS_CURRENT_V45 */ if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } return $location; } } return null; } function wayfarerApplyCurrentLocationTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } /* * Being between authored Locations is normal. Record an empty result * rather than treating the absence of a Location as an error. */ if (!$location) { $runtimeState['runtime']['last_location_id'] = null; $runtimeState['runtime']['last_location_tick_effects'] = []; return []; } $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : []; $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); /* * Location effects may target Current Region Traits, so load the active * Region's Trait definitions rather than the Location's authored Traits. */ $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false && $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $locationEffects = $locationConfig['tick_effects'] ?? []; if (!is_array($locationEffects)) { $locationEffects = []; } $reports = []; foreach ( $locationEffects as $effect ) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } $runtimeState['runtime']['last_location_id'] = $locationId; $runtimeState['runtime']['last_location_tick_effects'] = $reports; $runtimeState['runtime']['last_location_trait_bound_repairs'] = $boundRepairs; return $reports; } /* * WAYFARER_GAME_TRAVEL_MODIFIERS_RUNTIME_V73 * * Game Travel Modifiers are evaluated only when dedicated Linear Travel is * about to move the player. They do not mutate state by themselves and are * intentionally silent in normal Effect feedback. */ function wayfarerRuntimeEvaluateGameTravelModifiers( PDO $pdo, int $gameId, int $currentRegionId, array &$runtimeState ): array { $result = [ 'adjustment' => 0, 'matched' => [], ]; if ($gameId <= 0) { return $result; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig === false || $rawConfig === null || $rawConfig === '' ) { return $result; } $gameConfig = json_decode( (string)$rawConfig, true ); if (!is_array($gameConfig)) { return $result; } $modifiers = $gameConfig['travel']['modifiers'] ?? []; if (!is_array($modifiers)) { return $result; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; $adjustment = 0.0; foreach ( array_values($modifiers) as $modifier ) { if (!is_array($modifier)) { continue; } $operation = strtolower( trim( (string)( $modifier['operation'] ?? '' ) ) ); $value = $modifier['value'] ?? null; if ( !in_array( $operation, ['add', 'subtract'], true ) || !is_numeric($value) || (float)$value <= 0 ) { continue; } if ( !wayfarerConditionsPass( $modifier['conditions'] ?? null, $resolver ) ) { continue; } $delta = $operation === 'subtract' ? -(float)$value : (float)$value; $adjustment += $delta; $result['matched'][] = [ 'id' => (string)( $modifier['id'] ?? '' ), 'name' => (string)( $modifier['name'] ?? 'Travel Modifier' ), 'operation' => $operation, 'value' => wayfarerNormalizeRuntimeNumber( (float)$value ), 'adjustment' => wayfarerNormalizeRuntimeNumber( $delta ), ]; } $result['adjustment'] = wayfarerNormalizeRuntimeNumber( $adjustment ); return $result; } function wayfarerApplyCurrentRegionTickEffects( PDO $pdo, array &$runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ($regionId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active navigation region.', ] ]; /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Reports are returned for the current request, not persisted. */ return $reports; } $stmt = $pdo->prepare( " SELECT id, game_id, config FROM wf_regions WHERE id = ? LIMIT 1 " ); $stmt->execute([ $regionId ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { $reports = [ [ 'applied' => false, 'error' => 'Active Region ' . $regionId . ' was not found.', ] ]; /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Reports are returned for the current request, not persisted. */ return $reports; } $config = []; if (!empty($region['config'])) { $decoded = json_decode( $region['config'], true ); if (is_array($decoded)) { $config = $decoded; } } /* WAYFARER_LINEAR_TRAVEL_TICK_PIPELINE_V72 */ $dedicatedTravel = wayfarerRuntimeDedicatedLinearTravel( $config ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, (int)( $region['game_id'] ?? 0 ), $config ); /* * Enforce the invariant before any branch condition is evaluated. * This also repairs state produced by older runtime versions. */ $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $regionId ); $effects = $config['tick_effects'] ?? []; if (!is_array($effects)) { $effects = []; } $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed tick effect.', ]; continue; } /* * WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 * * A dedicated travel Region owns movement through config.travel. * Ignore only Region Tick Effects that target Navigation Position; * every other recurring Region Effect continues normally. */ if ( $dedicatedTravel !== null && strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ) === 'navigation' && strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ) === 'position' && (string)( $effect['target_id'] ?? '' ) === 'position' ) { $reports[] = [ 'scope' => 'navigation', 'type' => 'position', 'target_id' => 'position', 'applied' => false, 'ignored' => true, 'reason' => 'Dedicated Linear Travel owns Region movement.', 'error' => '', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $regionId, $traitDefinitions ); } /* WAYFARER_GAME_TRAVEL_MODIFIERS_RUNTIME_V73 */ if ($dedicatedTravel !== null) { $gameTravelModifierResult = wayfarerRuntimeEvaluateGameTravelModifiers( $pdo, (int)( $region['game_id'] ?? 0 ), $regionId, $runtimeState ); $reports[] = wayfarerRuntimeApplyDedicatedLinearTravelMovement( $runtimeState, $dedicatedTravel, (float)( $gameTravelModifierResult[ 'adjustment' ] ?? 0 ), $gameTravelModifierResult[ 'matched' ] ?? [] ); } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Tick reports and bound-repair reports are immediate diagnostics only. */ return $reports; } ================================================================================ FILE: _update_backups/v102-20260930-201953/play/includes/runtime.php ================================================================================ $mode, 'starting_day' => max(1, (int)($raw['starting_day'] ?? 1)), 'ticks_per_day' => max(1, (int)($raw['ticks_per_day'] ?? 1)), 'starting_hour' => max(0, (int)($raw['starting_hour'] ?? 8)), 'ticks_per_hour' => max(1, (int)($raw['ticks_per_hour'] ?? 1)), 'hours_per_day' => max(1, (int)($raw['hours_per_day'] ?? 24)), ]; if ($config['starting_hour'] >= $config['hours_per_day']) { $config['starting_hour'] = $config['hours_per_day'] - 1; } return $config; } function wayfarerRuntimeInitializeClockFromConfig( array $gameConfig, array &$runtimeState ): void { if (!isset($runtimeState['runtime']) || !is_array($runtimeState['runtime'])) { $runtimeState['runtime'] = []; } $config = wayfarerRuntimeTimeConfig($gameConfig); if ($config['mode'] === 'none') { unset($runtimeState['runtime']['clock']); return; } $clock = [ 'mode' => $config['mode'], 'day' => $config['starting_day'], ]; if ($config['mode'] === 'days') { $clock['tick_in_day'] = 0; } else { $clock['hour'] = $config['starting_hour']; $clock['tick_in_hour'] = 0; } $runtimeState['runtime']['clock'] = $clock; } function wayfarerRuntimeLoadGameConfig(PDO $pdo, int $gameId): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare('SELECT config FROM wf_games WHERE id = ? LIMIT 1'); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); return wayfarerRuntimeDecodeConfig($raw === false ? null : $raw); } function wayfarerRuntimeEnsureClock( PDO $pdo, int $gameId, array &$runtimeState ): array { $gameConfig = wayfarerRuntimeLoadGameConfig($pdo, $gameId); $timeConfig = wayfarerRuntimeTimeConfig($gameConfig); $clock = $runtimeState['runtime']['clock'] ?? null; if ($timeConfig['mode'] === 'none') { unset($runtimeState['runtime']['clock']); return $timeConfig; } if (!is_array($clock) || (string)($clock['mode'] ?? '') !== $timeConfig['mode']) { wayfarerRuntimeInitializeClockFromConfig($gameConfig, $runtimeState); } return $timeConfig; } function wayfarerRuntimeAdvanceClock( PDO $pdo, int $gameId, array &$runtimeState ): void { $config = wayfarerRuntimeEnsureClock($pdo, $gameId, $runtimeState); if ($config['mode'] === 'none') { return; } $clock =& $runtimeState['runtime']['clock']; $clock['day'] = max(1, (int)($clock['day'] ?? $config['starting_day'])); if ($config['mode'] === 'days') { $clock['tick_in_day'] = max(0, (int)($clock['tick_in_day'] ?? 0)) + 1; if ($clock['tick_in_day'] >= $config['ticks_per_day']) { $clock['day']++; $clock['tick_in_day'] = 0; } return; } $clock['hour'] = max(0, (int)($clock['hour'] ?? $config['starting_hour'])); $clock['tick_in_hour'] = max(0, (int)($clock['tick_in_hour'] ?? 0)) + 1; if ($clock['tick_in_hour'] >= $config['ticks_per_hour']) { $clock['tick_in_hour'] = 0; $clock['hour']++; if ($clock['hour'] >= $config['hours_per_day']) { $clock['hour'] = 0; $clock['day']++; } } } function wayfarerRuntimeClockLabel(array $runtimeState, array $timeConfig): string { if ($timeConfig['mode'] === 'none') { return ''; } $clock = $runtimeState['runtime']['clock'] ?? []; $day = max(1, (int)($clock['day'] ?? $timeConfig['starting_day'])); if ($timeConfig['mode'] === 'days') { return 'Day ' . $day; } $hour = max(0, (int)($clock['hour'] ?? $timeConfig['starting_hour'])); if ($timeConfig['hours_per_day'] === 24) { $displayHour = $hour % 12; if ($displayHour === 0) { $displayHour = 12; } $suffix = $hour < 12 ? 'AM' : 'PM'; return 'Day ' . $day . ' · ' . $displayHour . ':00 ' . $suffix; } return 'Day ' . $day . ' · Hour ' . $hour; } /* * WAYFARER_BIDIRECTIONAL_TRAVEL_V71 * * Runtime direction is journey state, not Region authoring state. +1 follows * the Region's authored forward Tick Effect; -1 reverses it. Keeping the * authored Add/Subtract sign as the base preserves existing Regions while * allowing the same line to be traversed in either direction. */ function wayfarerRuntimeNavigationDirection(array $runtimeState): int { $direction = (int)( $runtimeState['navigation']['direction'] ?? 1 ); return $direction < 0 ? -1 : 1; } /* * WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 * * Returns canonical dedicated travel configuration, or null for Regions that * still use the legacy Position Tick Effect movement path. */ function wayfarerRuntimeDedicatedLinearTravel( array $regionConfig ): ?array { $travel = isset($regionConfig['travel']) && is_array($regionConfig['travel']) ? $regionConfig['travel'] : null; if ( $travel === null || empty($travel['enabled']) || !isset($travel['base_distance']) || !is_numeric($travel['base_distance']) || (float)$travel['base_distance'] <= 0 ) { return null; } return [ 'base_distance' => (float)$travel['base_distance'], 'allow_turnaround' => !empty( $travel['allow_turnaround'] ), ]; } /* WAYFARER_GAME_TRAVEL_MODIFIERS_MOVEMENT_V73 */ function wayfarerRuntimeApplyDedicatedLinearTravelMovement( array &$runtimeState, array $travel, float $modifierAdjustment = 0.0, array $matchedModifiers = [] ): array { if ( !isset($runtimeState['navigation']) || !is_array($runtimeState['navigation']) ) { $runtimeState['navigation'] = []; } $before = $runtimeState['navigation']['position'] ?? 0; if (!is_numeric($before)) { return [ 'scope' => 'navigation', 'type' => 'position', 'target_id' => 'position', 'applied' => false, 'error' => 'Navigation position must be numeric.', ]; } $baseDistance = max( 0.0, (float)( $travel['base_distance'] ?? 0 ) ); /* * Modifiers may slow travel to zero, but never reverse the player's * chosen direction. Direction is applied only after effective distance is * clamped to a non-negative value. */ $distance = max( 0.0, $baseDistance + $modifierAdjustment ); $direction = wayfarerRuntimeNavigationDirection( $runtimeState ); $after = max( 0.0, (float)$before + ($distance * $direction) ); $after = wayfarerNormalizeRuntimeNumber( $after ); $runtimeState['navigation']['position'] = $after; return [ 'scope' => 'navigation', 'type' => 'position', 'target_id' => 'position', 'operation' => 'travel', 'base_distance' => wayfarerNormalizeRuntimeNumber( $baseDistance ), 'modifier_adjustment' => wayfarerNormalizeRuntimeNumber( $modifierAdjustment ), 'matched_modifiers' => array_values( $matchedModifiers ), 'amount' => wayfarerNormalizeRuntimeNumber( $distance ), 'direction' => $direction, 'before' => wayfarerNormalizeRuntimeNumber( (float)$before ), 'after' => $after, 'applied' => true, 'error' => '', ]; } function wayfarerRuntimeLinearTravelDirection( array $regionConfig, array $runtimeState ): int { $runtimeDirection = wayfarerRuntimeNavigationDirection( $runtimeState ); /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ if ( wayfarerRuntimeDedicatedLinearTravel( $regionConfig ) !== null ) { return $runtimeDirection; } $effects = isset($regionConfig['tick_effects']) && is_array($regionConfig['tick_effects']) ? $regionConfig['tick_effects'] : []; foreach ($effects as $effect) { if (!is_array($effect)) { continue; } if ( (string)($effect['target_scope'] ?? '') !== 'navigation' || (string)($effect['target_type'] ?? '') !== 'position' || (string)($effect['target_id'] ?? '') !== 'position' ) { continue; } $branches = wayfarerRuntimeTickEffectBranches($effect); foreach ($branches as $branch) { $operation = strtolower(trim((string)($branch['operation'] ?? ''))); if ($operation === 'subtract') { return -1 * $runtimeDirection; } if ($operation === 'add') { return 1 * $runtimeDirection; } } } return $runtimeDirection; } function wayfarerRuntimeBuildTravelStatus( PDO $pdo, int $gameId, array &$runtimeState ): array { $gameConfig = wayfarerRuntimeLoadGameConfig($pdo, $gameId); $timeConfig = wayfarerRuntimeEnsureClock($pdo, $gameId, $runtimeState); $result = [ 'time_label' => wayfarerRuntimeClockLabel($runtimeState, $timeConfig), 'region_name' => '', 'distance_label' => 'units', 'next_location_id' => 0, 'next_location_name' => '', 'distance_to_next' => null, 'next_label' => '', /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ 'dedicated_travel' => false, 'base_distance_per_tick' => null, 'allow_turnaround' => false, 'can_turn_around' => false, 'direction' => 1, ]; $regionId = (int)($runtimeState['navigation']['region_id'] ?? 0); $position = $runtimeState['navigation']['position'] ?? null; if ($regionId <= 0 || !is_numeric($position)) { return $result; } $stmt = $pdo->prepare( 'SELECT id, name, navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1' ); $stmt->execute([$regionId, $gameId]); $region = $stmt->fetch(PDO::FETCH_ASSOC); if (!$region) { return $result; } $result['region_name'] = trim((string)($region['name'] ?? '')); if (strtolower(trim((string)($region['navigation_type'] ?? ''))) !== 'linear') { return $result; } $regionConfig = wayfarerRuntimeDecodeConfig($region['config'] ?? null); $distanceLabel = trim((string)($regionConfig['distance_label'] ?? 'units')); $result['distance_label'] = $distanceLabel !== '' ? $distanceLabel : 'units'; $direction = wayfarerRuntimeLinearTravelDirection($regionConfig, $runtimeState); $current = (float)$position; /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ $dedicatedTravel = wayfarerRuntimeDedicatedLinearTravel( $regionConfig ); $result['direction'] = $direction; if ($dedicatedTravel !== null) { $result['dedicated_travel'] = true; $result['base_distance_per_tick'] = wayfarerNormalizeRuntimeNumber( (float)$dedicatedTravel[ 'base_distance' ] ); $result['allow_turnaround'] = !empty( $dedicatedTravel[ 'allow_turnaround' ] ); /* * Do not offer a reversal that would immediately point out of the * legal lower boundary. If already traveling toward Start at zero, * turning around toward End remains valid. */ $result['can_turn_around'] = $result['allow_turnaround'] && !( $current <= 0 && $direction > 0 ); } $locationStmt = $pdo->prepare( "SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id" ); $locationStmt->execute([$regionId]); $best = null; $bestDistance = null; foreach ($locationStmt->fetchAll(PDO::FETCH_ASSOC) as $location) { $locationConfig = wayfarerRuntimeDecodeConfig($location['config'] ?? null); if (!isset($locationConfig['position']) || !is_numeric($locationConfig['position'])) { continue; } $locationPosition = (float)$locationConfig['position']; $ahead = $direction < 0 ? $locationPosition < $current : $locationPosition > $current; if (!$ahead) { continue; } $location['_runtime_config'] = $locationConfig; if (!wayfarerRuntimeLocationConditionsPass($pdo, $gameId, $location, $runtimeState)) { continue; } $distance = abs($locationPosition - $current); if ($best === null || $distance < $bestDistance) { $best = $location; $bestDistance = $distance; } } if ($best !== null) { $distance = wayfarerNormalizeRuntimeNumber((float)$bestDistance); $name = trim((string)($best['name'] ?? 'Location')) ?: 'Location'; $result['next_location_id'] = (int)($best['id'] ?? 0); $result['next_location_name'] = $name; $result['distance_to_next'] = $distance; $result['next_label'] = $name . ' — ' . $distance . ' ' . $result['distance_label']; } return $result; } function wayfarerRuntimeActiveTraitDefinitions( array $config ): array { $traits = $config['traits'] ?? []; if (!is_array($traits)) { return []; } $result = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( array_key_exists( 'active', $trait ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $trait['_runtime_id'] = $traitId; $result[] = $trait; } return $result; } /* * WAYFARER_TRAIT_RANDOM_INITIAL_RUNTIME_V93 * * Number Traits may initialize from either one fixed value or one random * Min–Max range. This function is called only when the owning runtime Trait * value does not already exist, so a random initial value is rolled once and * then persisted like every other Trait value. */ function wayfarerRuntimeInitialTraitValue( array $trait ) { $type = strtolower( trim( (string)( $trait['type'] ?? '' ) ) ); $initialMode = strtolower( trim( (string)( $trait['initial_mode'] ?? 'fixed' ) ) ); if ( $type === 'number' && $initialMode === 'random' ) { $minimum = $trait['initial_min'] ?? null; $maximum = $trait['initial_max'] ?? null; if ( is_numeric($minimum) && is_numeric($maximum) && (float)$minimum <= (float)$maximum ) { $rolled = wayfarerRuntimeRollEffectValue([ 'value_mode' => 'random', 'value_min' => $minimum, 'value_max' => $maximum, ]); if (!empty($rolled['valid'])) { $value = $rolled['value']; /* * Authored Trait bounds remain invariants even if malformed * legacy JSON contains a random range outside those bounds. */ if ( function_exists( 'wayfarerRuntimeClampValueToTraitDefinition' ) ) { $bounded = wayfarerRuntimeClampValueToTraitDefinition( $value, $trait ); if (!empty($bounded['valid'])) { return $bounded['value']; } } return $value; } } } if ( array_key_exists( 'initial', $trait ) ) { return $trait['initial']; } if ( $type === 'number' || $type === 'binary' || $type === 'boolean' ) { return 0; } return null; } function wayfarerRuntimeInitialTraitState( array $config ): array { $state = []; foreach ( wayfarerRuntimeActiveTraitDefinitions( $config ) as $trait ) { $traitId = (string)$trait['_runtime_id']; $state[$traitId] = wayfarerRuntimeInitialTraitValue( $trait ); } return $state; } /* |-------------------------------------------------------------------------- | Persisted state for every Region |-------------------------------------------------------------------------- | WAYFARER_ALL_REGION_STATE_V46 | | Game-wide Conditions may inspect a Region that the player has never visited. | Therefore every authored Region needs persistent Trait state, not only the | current Region. | | This helper is additive/recovery-safe: | - existing runtime values are never overwritten; | - missing Regions or newly-added Traits receive their authored initial; | - stale historical values are not destructively removed here. | */ function wayfarerRuntimeEnsureAllRegionTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['regions']) || !is_array($runtimeState['regions']) ) { $runtimeState['regions'] = []; } $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? ORDER BY id " ); $stmt->execute([ $gameId ]); $regions = $stmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($regions as $region) { $regionId = (int)( $region['id'] ?? 0 ); if ($regionId <= 0) { continue; } $regionKey = (string)$regionId; $config = wayfarerRuntimeDecodeConfig( $region['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset( $runtimeState['regions'][$regionKey] ) || !is_array( $runtimeState['regions'][$regionKey] ) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset( $runtimeState['regions'][$regionKey]['traits'] ) || !is_array( $runtimeState['regions'][$regionKey]['traits'] ) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['regions'][$regionKey]['traits'] ) ) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $initialValue; } } } /* |-------------------------------------------------------------------------- | Persisted state for every Location | WAYFARER_ALL_LOCATION_STATE_V56 |-------------------------------------------------------------------------- | | Locations can own Traits, so universal Conditions/Effects need persistent | per-playthrough Location Trait state just like Regions. | */ function wayfarerRuntimeEnsureAllLocationTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['locations']) || !is_array($runtimeState['locations']) ) { $runtimeState['locations'] = []; } $stmt = $pdo->prepare( " SELECT l.id, l.region_id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $location ) { $locationId = (int)($location['id'] ?? 0); if ($locationId <= 0) { continue; } $locationKey = (string)$locationId; $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset($runtimeState['locations'][$locationKey]) || !is_array($runtimeState['locations'][$locationKey]) ) { $runtimeState['locations'][$locationKey] = []; } /* * WAYFARER_LIVE_LOCATION_ANCHORS_V76 * * These are definition-derived spatial facts, so refresh them every * ensure pass. Moving a Location in Builder therefore changes the * anchor without rewriting authored Conditions. */ $runtimeState['locations'][$locationKey]['region_id'] = (int)( $location['region_id'] ?? 0 ); $runtimeState['locations'][$locationKey]['position'] = isset($config['position']) && is_numeric($config['position']) ? (float)$config['position'] : null; if ( !isset($runtimeState['locations'][$locationKey]['traits']) || !is_array($runtimeState['locations'][$locationKey]['traits']) ) { $runtimeState['locations'][$locationKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['locations'][$locationKey]['traits'] ) ) { continue; } $runtimeState['locations'][$locationKey]['traits'][$traitId] = $initialValue; } } } function wayfarerRuntimeLoadStartingRegion( PDO $pdo, int $gameId, ?array $gameConfig = null ): ?array { if ($gameId <= 0) { return null; } if ($gameConfig === null) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { return null; } $gameConfig = wayfarerRuntimeDecodeConfig( $rawConfig ); } $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT * FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $startingRegionId, $gameId, ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); return $region ?: null; } function wayfarerRuntimeInitializeGame( PDO $pdo, int $gameUserId, array $setupInput = [] ): array { if ($gameUserId <= 0) { throw new RuntimeException( 'A valid game-user ID is required to initialize the game.' ); } try { $pdo->beginTransaction(); /* * Lock the playthrough row so two initialize requests cannot create * competing starting states. */ $stmt = $pdo->prepare( " SELECT gu.id, gu.game_id, gu.user_id, gu.role, gu.state, g.config AS game_config, g.player_slots AS player_slots FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id WHERE gu.id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } /* * Initialization is intentionally one-way. To restart a development * game, clear wf_game_users.state to NULL first. */ if ( $row['state'] !== null && $row['state'] !== '' ) { throw new RuntimeException( 'This game-user already has runtime state.' ); } $gameId = (int)$row['game_id']; $gameConfig = wayfarerRuntimeDecodeConfig( $row['game_config'] ?? null ); $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { throw new RuntimeException( 'This Game does not have a Starting Region configured.' ); } $startingRegion = wayfarerRuntimeLoadStartingRegion( $pdo, $gameId, $gameConfig ); if (!$startingRegion) { throw new RuntimeException( 'The configured Starting Region does not exist for this Game.' ); } $regionConfig = wayfarerRuntimeDecodeConfig( $startingRegion['config'] ?? null ); $gameTraits = wayfarerRuntimeInitialTraitState( $gameConfig ); $regionTraits = wayfarerRuntimeInitialTraitState( $regionConfig ); $navigation = [ 'region_id' => (int)$startingRegion['id'], /* WAYFARER_BIDIRECTIONAL_TRAVEL_V71 */ 'direction' => 1, ]; /* * Linear travel currently begins at coordinate zero. * Other navigation modes will get their own authored start-state * rules when those modes are implemented. */ if ( strtolower( trim( (string)( $startingRegion['navigation_type'] ?? '' ) ) ) === 'linear' ) { $navigation['position'] = 0; } $regionKey = (string)$startingRegion['id']; $state = [ /* * WAYFARER_RUNTIME_SCHEMA_V55 * WAYFARER_BIDIRECTIONAL_TRAVEL_V71 * Version the persisted playthrough contract explicitly. */ 'schema_version' => 2, 'game' => [ 'traits' => $gameTraits, ], 'navigation' => $navigation, 'regions' => [ $regionKey => [ 'traits' => $regionTraits, ], ], 'runtime' => [ 'tick' => 0, ], ]; /* WAYFARER_INITIALIZE_GAME_CLOCK_V61 */ wayfarerRuntimeInitializeClockFromConfig( $gameConfig, $state ); /* * WAYFARER_ALL_REGION_STATE_V46 * * Global conditions can inspect Region state before that Region has * ever been visited. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $state ); /* WAYFARER_INITIALIZE_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_V49 * * Items and Characters receive canonical per-playthrough state before * any starting setup effects are applied. */ wayfarerRuntimeEnsureEntityState( $pdo, $gameId, $state ); /* * WAYFARER_APPLY_STARTING_CHOICES_V36 * * Validate all player-entered setup data before persisting anything. * Then apply the selected options' starting effects to the canonical * initial runtime state. */ $preparedSetup = wayfarerRuntimePrepareStartingSetup( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $setupInput ); /* * WAYFARER_PLAYER_TRAITS_RUNTIME_V52 * * Persist the validated player-character names before creating the * per-player Trait state. Each slot receives independent values. */ $state['setup'] = [ 'player_names' => $preparedSetup['player_names'], 'choices' => $preparedSetup['choices'], ]; wayfarerRuntimeEnsureGameTraitState( $gameConfig, $state ); wayfarerRuntimeEnsurePlayerState( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $state ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $startingEffectReports = []; foreach ( $preparedSetup['effects'] as $startingEffect ) { /* WAYFARER_STARTING_CHOICE_RANDOM_RUNTIME_V58 */ $resolvedStartingValue = wayfarerRuntimeRollEffectValue( $startingEffect ); if (!$resolvedStartingValue['valid']) { throw new RuntimeException( 'Unable to resolve a Starting Choice effect value: ' . $resolvedStartingValue['error'] ); } $concreteStartingEffect = $startingEffect; $concreteStartingEffect['value'] = $resolvedStartingValue['value']; $report = wayfarerApplyRuntimeEffect( $state, $concreteStartingEffect, (int)$startingRegion['id'], $traitDefinitions ); if ( !empty($report['error']) ) { throw new RuntimeException( 'Unable to apply a Starting Choice effect: ' . $report['error'] ); } $report['value_mode'] = $resolvedStartingValue['mode']; $report['rolled_value'] = $resolvedStartingValue['value']; if ($resolvedStartingValue['mode'] === 'random') { $report['random_min'] = $resolvedStartingValue['min']; $report['random_max'] = $resolvedStartingValue['max']; } $startingEffectReports[] = $report; } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * * Starting-effect reports are intentionally not persisted. The effects * themselves have already mutated canonical state. */ /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 * * A game that begins exactly on an active Location should present that * Location before ordinary Action Mode. */ $startingLocation = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $state ); if ($startingLocation) { /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Starting on a Location is a real Location trigger. Apply its * one-time Effects before opening Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, $gameId, $startingLocation, $state ); $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$startingLocation['id'], 'phase' => 'presentation', 'trigger' => 'initial', 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /* WAYFARER_GAME_ENDINGS_INITIAL_V45 */ wayfarerRuntimeEvaluateGameEndings( $pdo, $gameId, $state ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => $gameId, 'starting_region_id' => (int)$startingRegion['id'], 'state' => $state, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Resolve a game-user record for the tick prototype. * * Preferred: * explicit wf_game_users.id * * Temporary development fallback: * first wf_game_users record. * * The fallback exists only because Wayfarer has not yet wired the play * page to authentication / game selection. Once that exists, remove the * fallback and always pass the active game_user_id explicitly. */ function wayfarerRuntimeResolveGameUserId( PDO $pdo, ?int $requestedId = null ): int { if ($requestedId !== null && $requestedId > 0) { $stmt = $pdo->prepare( " SELECT id FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $requestedId ]); $id = $stmt->fetchColumn(); if ($id !== false) { return (int)$id; } throw new RuntimeException( 'The requested game-user record does not exist.' ); } $id = $pdo ->query( " SELECT id FROM wf_game_users ORDER BY id LIMIT 1 " ) ->fetchColumn(); if ($id === false) { throw new RuntimeException( 'No wf_game_users record exists.' ); } return (int)$id; } /** * Load one game-user runtime state. */ function wayfarerRuntimeLoad( PDO $pdo, int $gameUserId ): array { $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'state' => $state, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /** * Advance exactly one tick. * * This is intentionally the entire v14 game engine: * * state.runtime.tick += 1 * * The transaction and row lock are already in place so later work can add * movement/effects without changing the request architecture. */ /* |-------------------------------------------------------------------------- | Linear Location crossing | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | A tick may move farther than the distance to the next authored Location. | This resolver finds the FIRST active Location crossed in the direction of | travel. The movement is then clamped to that exact authored coordinate. | | Examples: | 45 -> 55, Location 50 => stop at 50 | 55 -> 45, Location 50 => stop at 50 | | The starting coordinate is intentionally exclusive so leaving a Location | does not immediately trigger that same Location again. | */ /* |-------------------------------------------------------------------------- | Location Conditions | WAYFARER_LOCATION_CONDITIONS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeLocationConditionsPass( PDO $pdo, int $gameId, array $location, array &$runtimeState ): bool { $config = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $conditions = $config['conditions'] ?? null; $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; return wayfarerConditionsPass( $conditions, $resolver ); } /* |-------------------------------------------------------------------------- | Game Endings | WAYFARER_GAME_ENDINGS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeEvaluateGameEndings( PDO $pdo, int $gameId, array &$runtimeState ): ?array { /* * WAYFARER_ALL_REGION_STATE_ENDINGS_V46 */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $runtimeState ); /* WAYFARER_RECOVER_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $runtimeState ); if ( isset( $runtimeState['runtime']['ending'] ) && is_array( $runtimeState['runtime']['ending'] ) ) { return $runtimeState['runtime']['ending']; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); $config = wayfarerRuntimeDecodeConfig( $raw === false ? null : $raw ); $endings = $config['endings'] ?? []; if (!is_array($endings)) { return null; } $ordered = []; foreach ( array_values($endings) as $index => $ending ) { if (!is_array($ending)) { continue; } $ending['_runtime_order'] = $index; $ordered[] = $ending; } usort( $ordered, function ( array $a, array $b ): int { $priorityCompare = (int)( $b['priority'] ?? 0 ) <=> (int)( $a['priority'] ?? 0 ); if ($priorityCompare !== 0) { return $priorityCompare; } return (int)( $a['_runtime_order'] ?? 0 ) <=> (int)( $b['_runtime_order'] ?? 0 ); } ); $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; foreach ($ordered as $ending) { if ( !wayfarerConditionsPass( $ending['conditions'] ?? null, $resolver ) ) { continue; } $terminal = [ 'id' => (string)( $ending['id'] ?? '' ), 'title' => (string)( $ending['title'] ?? 'The End' ), 'text' => (string)( $ending['text'] ?? '' ), 'image_media_id' => (int)( $ending['image_media_id'] ?? 0 ), 'priority' => (int)( $ending['priority'] ?? 0 ), 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), ]; if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['ending'] = $terminal; unset( $runtimeState['runtime']['presentation'] ); return $terminal; } return null; } function wayfarerRuntimeFindCrossedLocation( PDO $pdo, int $gameId, int $regionId, $fromPosition, $toPosition, array &$runtimeState ): ?array { /* * WAYFARER_CROSSED_LOCATION_STATE_FIX_V46_1 * * Location Conditions are evaluated against the live runtime state. */ if ( $gameId <= 0 || $regionId <= 0 || !is_numeric($fromPosition) || !is_numeric($toPosition) ) { return null; } $from = (float)$fromPosition; $to = (float)$toPosition; if ($from === $to) { return null; } /* * Crossing interception currently applies only to Linear Regions. */ $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); $forward = $to > $from; $candidate = null; $candidatePosition = null; foreach ($locations as $location) { $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $locationPosition = (float)$config['position']; /* * The starting point is exclusive; the destination is inclusive. */ $crossed = $forward ? ( $locationPosition > $from && $locationPosition <= $to ) : ( $locationPosition < $from && $locationPosition >= $to ); if (!$crossed) { continue; } $location['_runtime_config'] = $config; if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } if ($candidate === null) { $candidate = $location; $candidatePosition = $locationPosition; } else { $nearer = $forward ? ( $locationPosition < $candidatePosition ) : ( $locationPosition > $candidatePosition ); $samePositionLowerId = $locationPosition === $candidatePosition && (int)$location['id'] < (int)$candidate['id']; if ( $nearer || $samePositionLowerId ) { $candidate = $location; $candidatePosition = $locationPosition; } } } if ($candidate === null) { return null; } $candidate['_runtime_position'] = wayfarerNormalizeRuntimeNumber( (float)$candidatePosition ); $candidate['_runtime_config'] = wayfarerRuntimeDecodeConfig( $candidate['config'] ?? null ); return $candidate; } /* |-------------------------------------------------------------------------- | Player spatial context |-------------------------------------------------------------------------- | WAYFARER_PLAYER_CONTEXT_V46_1 | | This is the canonical player-facing spatial label for Action Mode: | | exact eligible Location -> Location name + Region parent context | otherwise -> Region name | | It is returned by runtime APIs so the browser does not keep displaying the | Location name from the page's original server render after movement. | */ function wayfarerRuntimePlayerContext( PDO $pdo, int $gameId, array $runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionName = ''; if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $rawRegionName = $stmt->fetchColumn(); if ($rawRegionName !== false) { $regionName = trim( (string)$rawRegionName ); } } $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); $locationId = 0; $locationName = ''; if ($location) { $locationId = (int)( $location['id'] ?? 0 ); $locationName = trim( (string)( $location['name'] ?? '' ) ); } $contextName = $locationName !== '' ? $locationName : ( $regionName !== '' ? $regionName : 'Actions' ); return [ 'region_id' => $regionId, 'region_name' => $regionName, 'location_id' => $locationId, 'location_name' => $locationName, 'context_name' => $contextName, 'parent_context' => $locationName !== '' ? $regionName : '', ]; } /* |-------------------------------------------------------------------------- | Presentation dismissal |-------------------------------------------------------------------------- | | Presentation is persisted runtime state. This function is intentionally | generic even though v40 renders only Location presentations; Storylets can | use the same mechanism later. | */ function wayfarerRuntimeDismissPresentation( PDO $pdo, int $gameUserId, string $expectedType = '', int $expectedId = 0 ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); $presentation = $state['runtime']['presentation'] ?? null; if ( !is_array($presentation) || empty($presentation['type']) || empty($presentation['id']) ) { throw new RuntimeException( 'There is no active presentation to continue.' ); } if ( $expectedType !== '' && (string)$presentation['type'] !== $expectedType ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } if ( $expectedId > 0 && (int)$presentation['id'] !== $expectedId ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } /* WAYFARER_STORYLET_BRANCH_DISMISS_GUARD_V59 */ if ( (string)($presentation['type'] ?? '') === 'storylet' ) { $eligibleBranches = wayfarerRuntimeEligibleStoryletBranches( $pdo, (int)$row['game_id'], (int)($presentation['id'] ?? 0), $state ); if ($eligibleBranches) { throw new RuntimeException( 'Choose an available Storylet Branch before continuing.' ); } } $state['runtime']['last_presentation'] = $presentation; unset( $state['runtime']['presentation'] ); /* WAYFARER_STATUS_STORYLET_QUEUE_V88 */ wayfarerRuntimePromotePendingStatusStorylet( $pdo, (int)$row['game_id'], $state ); wayfarerRuntimeEvaluateGameEndings( $pdo, (int)$row['game_id'], $state ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'dismissed' => $presentation, /* WAYFARER_STORYLET_ACTIONS_AFTER_PRESENTATION_V59 */ 'actions' => isset($state['runtime']['presentation']) && is_array($state['runtime']['presentation']) ? [] : wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$row['game_id'], $state ), /* WAYFARER_STATUS_STORYLET_QUEUE_V88 */ 'presentation' => isset($state['runtime']['presentation']) && is_array($state['runtime']['presentation']) ? $state['runtime']['presentation'] : null, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Advance exactly one tick. * * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ function wayfarerRuntimeAdvanceTick( PDO $pdo, int $gameUserId, ?int $requestedTravelDirection = null ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } /* WAYFARER_BIDIRECTIONAL_TRAVEL_V71 */ if ( !isset($state['navigation']) || !is_array($state['navigation']) ) { $state['navigation'] = []; } if ( $requestedTravelDirection !== null && !in_array( $requestedTravelDirection, [-1, 1], true ) ) { throw new RuntimeException( 'Travel direction must be -1 or 1.' ); } $state['navigation']['direction'] = $requestedTravelDirection !== null ? $requestedTravelDirection : wayfarerRuntimeNavigationDirection( $state ); $state['schema_version'] = max( 2, (int)( $state['schema_version'] ?? 1 ) ); /* * WAYFARER_GAME_ENDINGS_TERMINAL_V45 * * Endings are terminal runtime state. */ if ( isset( $state['runtime']['ending'] ) && is_array( $state['runtime']['ending'] ) ) { throw new RuntimeException( 'This game has ended.' ); } /* * Narrative presentation is a true runtime pause, not merely a hidden * browser control. Even Author Mode cannot advance ticks through it. */ if ( isset( $state['runtime']['presentation'] ) && is_array( $state['runtime']['presentation'] ) ) { throw new RuntimeException( 'Continue the current presentation before advancing time.' ); } /* * WAYFARER_ALL_REGION_STATE_TICK_V46 * * Recovery path for playthroughs initialized before v46, and for * Regions/Traits authored after a playthrough began. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_LOCATION_STATE_TICK_RECOVERY_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, (int)$row['game_id'], $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_TICK_V49 * * Recovery path for playthroughs created before V49 and for Entities * or Traits authored after play began. */ wayfarerRuntimeEnsureEntityState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_PLAYER_TRAITS_TICK_RECOVERY_V52 */ wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$row['game_id'], $state ); $regionIdBeforeTick = (int)( $state['navigation']['region_id'] ?? 0 ); $positionBeforeTick = $state['navigation']['position'] ?? null; $currentTick = (int)( $state['runtime']['tick'] ?? 0 ); $state['runtime']['tick'] = $currentTick + 1; /* WAYFARER_ADVANCE_GAME_CLOCK_V61 */ wayfarerRuntimeAdvanceClock( $pdo, (int)$row['game_id'], $state ); /* * Global Game Tick Effects run first. */ wayfarerApplyGameTickEffects( $pdo, (int)$row['game_id'], $state ); /* * Current Region Tick Effects then perform Region-level movement and * state changes. */ wayfarerApplyCurrentRegionTickEffects( $pdo, $state ); /* * Resolve whether the completed movement would have crossed an active * Location. If so, stop at the nearest crossed Location rather than * accepting the overshot position. */ $regionIdAfterMovement = (int)( $state['navigation']['region_id'] ?? 0 ); $positionAfterMovement = $state['navigation']['position'] ?? null; $crossedLocation = null; /* * We only interpret movement as a continuous path while the player * remains in the same Region. Region transitions will later get their * own explicit navigation semantics. */ if ( $regionIdBeforeTick > 0 && $regionIdBeforeTick === $regionIdAfterMovement ) { $crossedLocation = wayfarerRuntimeFindCrossedLocation( $pdo, (int)$row['game_id'], $regionIdAfterMovement, $positionBeforeTick, $positionAfterMovement, $state ); } $arrivalPosition = null; if ($crossedLocation) { $arrivalPosition = $crossedLocation[ '_runtime_position' ]; $state['navigation']['position'] = $arrivalPosition; /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Event Effects execute once at the trigger boundary. They run * before the Location's Tick Effects and before Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, (int)$row['game_id'], $crossedLocation, $state ); } /* * WAYFARER_LOCATION_EFFECTS_ONLY_RUNTIME_V43 * * Locations no longer own Tick Effects. Game and Region Tick Effects * have already advanced the simulation; a crossed Location contributes * its one-time Effects and then interrupts with Presentation Mode. */ if ($crossedLocation) { $state['navigation']['position'] = $arrivalPosition; $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$crossedLocation['id'], 'phase' => 'presentation', 'trigger' => 'arrival', 'tick' => (int)$state['runtime']['tick'], ]; $state['runtime']['last_location_arrival'] = [ 'location_id' => (int)$crossedLocation['id'], 'region_id' => $regionIdAfterMovement, 'position' => $arrivalPosition, 'tick' => (int)$state['runtime']['tick'], ]; } /* WAYFARER_STATUS_LIFECYCLE_TICK_V88 */ wayfarerRuntimeProcessStatusProgression( $pdo, (int)$row['game_id'], $state ); /* * A Location presentation keeps priority for the completed travel * tick. Status Storylets queue behind it and are promoted as soon as * there is no active presentation. */ wayfarerRuntimePromotePendingStatusStorylet( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_GAME_ENDINGS_AFTER_TICK_V45 */ $ending = wayfarerRuntimeEvaluateGameEndings( $pdo, (int)$row['game_id'], $state ); $state['runtime']['last_tick_at'] = gmdate('c'); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId ]); $pdo->commit(); $presentation = $state['runtime']['presentation'] ?? null; $pausedForPresentation = is_array($presentation) && !empty($presentation['type']) && !empty($presentation['id']); $ending = $state['runtime']['ending'] ?? null; $ended = is_array($ending) && !empty($ending['id']); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'tick' => (int)$state['runtime']['tick'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'pause' => $pausedForPresentation || $ended, 'pause_reason' => $ended ? 'Game ended.' : ( $pausedForPresentation ? ( (string)($presentation['type'] ?? '') === 'storylet' ? 'Storylet opened.' : 'Location reached.' ) : '' ), 'presentation' => $pausedForPresentation ? $presentation : null, 'ending' => $ended ? $ending : null, /* WAYFARER_STORYLET_ACTIONS_TICK_API_V59 */ 'actions' => !$pausedForPresentation && !$ended ? wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$row['game_id'], $state ) : [], /* WAYFARER_TRAVEL_TIME_HUD_V61 */ 'travel' => wayfarerRuntimeBuildTravelStatus( $pdo, (int)$row['game_id'], $state ), ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: _update_backups/v102-20260930-201953/play/includes/storylets.php ================================================================================ root Manual Storylet -> Storylet presentation | -> eligible child Branch -> child presentation -> ... | | Automatic/Chance firing is intentionally not handled here yet. | */ function wayfarerRuntimeLoadStorylet( PDO $pdo, int $gameId, int $storyletId ): ?array { if ($gameId <= 0 || $storyletId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT id, game_id, name, description, parent_storylet_id, branch_label, branch_sort_order, trigger_type, max_occurrences, status, config FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $storyletId, $gameId, ]); $storylet = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$storylet) { return null; } $storylet['_runtime_config'] = wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); return $storylet; } function wayfarerRuntimeStoryletOccurrenceCount( array $runtimeState, int $storyletId ): int { if ($storyletId <= 0) { return 0; } return max( 0, (int)( $runtimeState['storylets'][(string)$storyletId]['occurrences'] ?? 0 ) ); } function wayfarerRuntimeStoryletOccurrenceLimitReached( array $storylet, array $runtimeState ): bool { $maximum = $storylet['max_occurrences'] ?? null; if ( $maximum === null || $maximum === '' || !is_numeric($maximum) ) { return false; } $maximum = (int)$maximum; if ($maximum <= 0) { return false; } return wayfarerRuntimeStoryletOccurrenceCount( $runtimeState, (int)($storylet['id'] ?? 0) ) >= $maximum; } function wayfarerRuntimeStoryletConditionsPass( array $storylet, array &$runtimeState ): bool { $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); $conditions = $config['conditions'] ?? null; $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; return wayfarerConditionsPass( $conditions, $resolver ); } function wayfarerRuntimeStoryletIsEligible( array $storylet, array &$runtimeState ): bool { if ( strtolower( trim( (string)( $storylet['status'] ?? '' ) ) ) !== 'active' ) { return false; } if ( wayfarerRuntimeStoryletOccurrenceLimitReached( $storylet, $runtimeState ) ) { return false; } return wayfarerRuntimeStoryletConditionsPass( $storylet, $runtimeState ); } function wayfarerRuntimeStoryletPlayerPayload( array $storylet, array $runtimeState, bool $asBranch = false ): array { $name = trim( (string)( $storylet['name'] ?? '' ) ); $branchLabel = trim( (string)( $storylet['branch_label'] ?? '' ) ); $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); return [ 'id' => (int)( $storylet['id'] ?? 0 ), 'name' => $name, 'label' => $asBranch && $branchLabel !== '' ? $branchLabel : $name, 'description' => trim( (string)( $storylet['description'] ?? '' ) ), 'priority' => (int)( $config['priority'] ?? 100 ), 'occurrences' => wayfarerRuntimeStoryletOccurrenceCount( $runtimeState, (int)( $storylet['id'] ?? 0 ) ), 'max_occurrences' => $storylet['max_occurrences'] === null || $storylet['max_occurrences'] === '' ? null : (int)$storylet['max_occurrences'], ]; } function wayfarerRuntimeEligibleRootManualStorylets( PDO $pdo, int $gameId, array &$runtimeState ): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare( " SELECT id, game_id, name, description, parent_storylet_id, branch_label, branch_sort_order, trigger_type, max_occurrences, status, config FROM wf_storylets WHERE game_id = ? AND status = 'active' AND trigger_type = 'manual' AND ( parent_storylet_id IS NULL OR parent_storylet_id = 0 ) ORDER BY id " ); $stmt->execute([ $gameId ]); $actions = []; foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $storylet ) { $storylet['_runtime_config'] = wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $runtimeState ) || !wayfarerRuntimeStoryletBindingsAvailable( $pdo, $gameId, $storylet, $runtimeState ) ) { continue; } $actions[] = wayfarerRuntimeStoryletPlayerPayload( $storylet, $runtimeState, false ); } usort( $actions, function ( array $a, array $b ): int { $priority = ((int)($a['priority'] ?? 100)) <=> ((int)($b['priority'] ?? 100)); if ($priority !== 0) { return $priority; } $label = strcasecmp( (string)($a['label'] ?? ''), (string)($b['label'] ?? '') ); if ($label !== 0) { return $label; } return ((int)($a['id'] ?? 0)) <=> ((int)($b['id'] ?? 0)); } ); return $actions; } function wayfarerRuntimeEligibleStoryletBranches( PDO $pdo, int $gameId, int $parentStoryletId, array &$runtimeState ): array { if ( $gameId <= 0 || $parentStoryletId <= 0 ) { return []; } $stmt = $pdo->prepare( " SELECT id, game_id, name, description, parent_storylet_id, branch_label, branch_sort_order, trigger_type, max_occurrences, status, config FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? AND status = 'active' ORDER BY branch_sort_order, name, id " ); $stmt->execute([ $gameId, $parentStoryletId, ]); $branches = []; foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $storylet ) { $storylet['_runtime_config'] = wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $runtimeState ) || !wayfarerRuntimeStoryletBindingsAvailable( $pdo, $gameId, $storylet, $runtimeState ) ) { continue; } $payload = wayfarerRuntimeStoryletPlayerPayload( $storylet, $runtimeState, true ); $payload['branch_sort_order'] = (int)( $storylet['branch_sort_order'] ?? 0 ); $branches[] = $payload; } return $branches; } function wayfarerRuntimeEnsureStoryletTargetState( PDO $pdo, int $gameId, array &$runtimeState ): void { wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $runtimeState ); wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $runtimeState ); wayfarerRuntimeEnsureEntityState( $pdo, $gameId, $runtimeState ); wayfarerRuntimeEnsurePlayerAndGameState( $pdo, $gameId, $runtimeState ); } function wayfarerRuntimeStoryletTraitDefinitions( PDO $pdo, int $gameId, array $runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionConfig = []; if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $raw = $stmt->fetchColumn(); if ($raw !== false) { $regionConfig = wayfarerRuntimeDecodeTraitConfig( $raw ); } } return wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); } /* |-------------------------------------------------------------------------- | Storylet-to-root navigation runtime | WAYFARER_STORYLET_ROOT_CONNECTIONS_V82 |-------------------------------------------------------------------------- */ function wayfarerRuntimeConsumeStoryletTransition( array &$runtimeState ): int { $storyletId = (int)( $runtimeState['runtime']['pending_storylet_transition'] ?? 0 ); if ( isset($runtimeState['runtime']) && is_array($runtimeState['runtime']) ) { unset( $runtimeState['runtime']['pending_storylet_transition'] ); } return max( 0, $storyletId ); } function wayfarerRuntimeEnterLinkedRootStorylet( PDO $pdo, int $gameId, int $storyletId, array &$runtimeState, array $visitedStorylets = [], array $inheritedBindings = [] ): array { $chain = []; $nextStoryletId = $storyletId; while ($nextStoryletId > 0) { if (count($chain) >= 16) { throw new RuntimeException( 'Storylet Continue To chain is too deep.' ); } if ( isset( $visitedStorylets[ (string)$nextStoryletId ] ) ) { throw new RuntimeException( 'Storylet Continue To would create a circular Storylet chain.' ); } $visitedStorylets[ (string)$nextStoryletId ] = true; $linkedStorylet = wayfarerRuntimeLoadStorylet( $pdo, $gameId, $nextStoryletId ); if (!$linkedStorylet) { throw new RuntimeException( 'The linked root Storylet could not be found.' ); } if ( !empty( $linkedStorylet[ 'parent_storylet_id' ] ) ) { throw new RuntimeException( 'Storylet Continue To may target only a root Storylet.' ); } wayfarerRuntimeEnsureStoryletTargetState( $pdo, $gameId, $runtimeState ); if ( !wayfarerRuntimeStoryletIsEligible( $linkedStorylet, $runtimeState ) ) { throw new RuntimeException( 'The linked root Storylet is not currently eligible.' ); } $linkedConfig = $linkedStorylet[ '_runtime_config' ] ?? []; $tickCost = max( 0, (int)( $linkedConfig[ 'tick_cost' ] ?? 0 ) ); if ($tickCost > 0) { throw new RuntimeException( 'Storylet Tick Cost runtime is not implemented yet. Set Tick Cost to 0 for linked Storylets.' ); } /* WAYFARER_PERSISTENT_STORYLET_SUBJECT_V88 */ $linkedPresetBindings = []; if ( isset($inheritedBindings['subject']) && is_array($inheritedBindings['subject']) ) { $subjectScope = (string)( $inheritedBindings['subject']['runtime_scope'] ?? '' ); $subjectRefresh = wayfarerRuntimeStoryletPresetCharacterBinding( $pdo, $gameId, $linkedStorylet, $runtimeState, $subjectScope, 'subject', (string)( $inheritedBindings['subject']['label'] ?? 'Subject' ) ); if (($subjectRefresh['error'] ?? '') !== '') { throw new RuntimeException( (string)$subjectRefresh['error'] ); } if (is_array($subjectRefresh['binding'] ?? null)) { $linkedPresetBindings['subject'] = $subjectRefresh['binding']; } } $bindingResult = wayfarerRuntimeResolveStoryletBindings( $pdo, $gameId, $linkedStorylet, $runtimeState, $linkedPresetBindings ); if ( ($bindingResult['error'] ?? '') !== '' ) { throw new RuntimeException( (string)$bindingResult['error'] ); } $resolvedBindings = is_array( $bindingResult['bindings'] ?? null ) ? $bindingResult['bindings'] : []; /* WAYFARER_PERSISTENT_STORYLET_SUBJECT_V88 */ $inheritedBindings = isset($resolvedBindings['subject']) && is_array($resolvedBindings['subject']) ? [ 'subject' => $resolvedBindings['subject'], ] : []; $effects = wayfarerRuntimeApplyStoryletEffects( $pdo, $gameId, $linkedStorylet, $runtimeState, $resolvedBindings ); $occurrence = wayfarerRuntimeIncrementStoryletOccurrence( $runtimeState, $nextStoryletId ); $chain[] = [ 'storylet_id' => $nextStoryletId, 'bindings' => $resolvedBindings, 'effects' => $effects, 'occurrence' => $occurrence, ]; $nextTransitionId = wayfarerRuntimeConsumeStoryletTransition( $runtimeState ); if ($nextTransitionId <= 0) { return [ 'storylet' => $linkedStorylet, 'bindings' => $resolvedBindings, 'effects' => $effects, 'occurrence' => $occurrence, 'chain' => $chain, ]; } $nextStoryletId = $nextTransitionId; } throw new RuntimeException( 'Storylet Continue To did not resolve to a valid Storylet.' ); } /* * Enter a root Storylet from an internal gameplay event while already inside * the caller's locked runtime transaction. Status Outcomes use this path so * they can supply the affected character as the persistent "subject" binding. * WAYFARER_TRIGGERED_STORYLET_RUNTIME_V88 */ function wayfarerRuntimeEnterTriggeredStoryletInState( PDO $pdo, int $gameId, int $storyletId, array &$runtimeState, array $presetBindings = [], string $trigger = 'event' ): array { if ( isset($runtimeState['runtime']['presentation']) && is_array($runtimeState['runtime']['presentation']) ) { throw new RuntimeException( 'A triggered Storylet cannot open while another presentation is active.' ); } $storylet = wayfarerRuntimeLoadStorylet( $pdo, $gameId, $storyletId ); if (!$storylet) { throw new RuntimeException( 'Triggered Storylet could not be found.' ); } if (!empty($storylet['parent_storylet_id'])) { throw new RuntimeException( 'Triggered Storylets must be root Storylets.' ); } if ( strtolower(trim((string)($storylet['status'] ?? 'draft'))) !== 'active' ) { throw new RuntimeException( 'Triggered Storylet is not Active.' ); } wayfarerRuntimeEnsureStoryletTargetState( $pdo, $gameId, $runtimeState ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $runtimeState ) ) { throw new RuntimeException( 'Triggered Storylet is not currently eligible.' ); } $config = $storylet['_runtime_config'] ?? []; if ( max(0, (int)($config['tick_cost'] ?? 0)) > 0 ) { throw new RuntimeException( 'Triggered Storylet Tick Cost runtime is not implemented yet. Set Tick Cost to 0.' ); } $bindingResult = wayfarerRuntimeResolveStoryletBindings( $pdo, $gameId, $storylet, $runtimeState, $presetBindings ); if (($bindingResult['error'] ?? '') !== '') { throw new RuntimeException( (string)$bindingResult['error'] ); } $resolvedBindings = is_array($bindingResult['bindings'] ?? null) ? $bindingResult['bindings'] : []; $effects = wayfarerRuntimeApplyStoryletEffects( $pdo, $gameId, $storylet, $runtimeState, $resolvedBindings ); $occurrence = wayfarerRuntimeIncrementStoryletOccurrence( $runtimeState, $storyletId ); $presentationStorylet = $storylet; $presentationBindings = $resolvedBindings; $presentationOccurrence = $occurrence; $presentationTrigger = strtolower(trim($trigger)); $transitionId = wayfarerRuntimeConsumeStoryletTransition( $runtimeState ); if ($transitionId > 0) { $linkedResult = wayfarerRuntimeEnterLinkedRootStorylet( $pdo, $gameId, $transitionId, $runtimeState, [ (string)$storyletId => true, ], isset($resolvedBindings['subject']) && is_array($resolvedBindings['subject']) ? [ 'subject' => $resolvedBindings['subject'], ] : [] ); $presentationStorylet = $linkedResult['storylet']; $presentationBindings = $linkedResult['bindings']; $presentationOccurrence = $linkedResult['occurrence']; $presentationTrigger = 'transition'; } $presentationStoryletId = (int)($presentationStorylet['id'] ?? 0); $runtimeState['runtime']['presentation'] = [ 'type' => 'storylet', 'id' => $presentationStoryletId, 'phase' => 'presentation', 'trigger' => $presentationTrigger, 'tick' => (int)($runtimeState['runtime']['tick'] ?? 0), 'bindings' => $presentationBindings, ]; $runtimeState['runtime']['last_storylet_entry'] = [ 'storylet_id' => $presentationStoryletId, 'parent_storylet_id' => (int)($presentationStorylet['parent_storylet_id'] ?? 0), 'entry_mode' => $presentationTrigger, 'occurrence' => $presentationOccurrence, 'bindings' => $presentationBindings, 'tick' => (int)($runtimeState['runtime']['tick'] ?? 0), ]; return [ 'storylet' => $presentationStorylet, 'bindings' => $presentationBindings, 'effects' => $effects, 'occurrence' => $presentationOccurrence, 'presentation' => $runtimeState['runtime']['presentation'], ]; } function wayfarerRuntimeApplyStoryletEffects( PDO $pdo, int $gameId, array $storylet, array &$runtimeState, array $bindings = [] ): array { $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); $effects = $config['effects'] ?? []; if (!is_array($effects)) { $effects = []; } $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $traitDefinitions = wayfarerRuntimeStoryletTraitDefinitions( $pdo, $gameId, $runtimeState ); wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $regionId ); $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { throw new RuntimeException( 'Malformed Storylet Effect.' ); } $materialized = wayfarerRuntimeMaterializeStoryletBindingEffect( $effect, $bindings ); if ( ($materialized['error'] ?? '') !== '' ) { throw new RuntimeException( 'Storylet Binding Effect failed: ' . (string)$materialized['error'] ); } $runtimeEffect = $materialized['effect'] ?? $effect; if ( (string)( $runtimeEffect['target_scope'] ?? '' ) === 'storylet' && (string)( $runtimeEffect['target_type'] ?? '' ) === 'transition' ) { $selection = wayfarerRuntimeSelectEffectBranch( $runtimeState, $runtimeEffect, $regionId ); if ( ($selection['error'] ?? '') !== '' ) { throw new RuntimeException( 'Storylet Continue To Effect failed: ' . (string)$selection['error'] ); } $matched = !empty( $selection['matched'] ); $branch = is_array( $selection['branch'] ?? null ) ? $selection['branch'] : []; $operation = strtolower( trim( (string)( $branch['operation'] ?? 'none' ) ) ); $targetStoryletId = (int)( $runtimeEffect['target_id'] ?? 0 ); $applied = $matched && $operation === 'continue'; if ($applied) { if ($targetStoryletId <= 0) { throw new RuntimeException( 'Storylet Continue To Effect has an invalid destination.' ); } $existingTransition = (int)( $runtimeState['runtime']['pending_storylet_transition'] ?? 0 ); if ( $existingTransition > 0 && $existingTransition !== $targetStoryletId ) { throw new RuntimeException( 'More than one Storylet Continue To Effect matched.' ); } $runtimeState['runtime']['pending_storylet_transition'] = $targetStoryletId; } $reports[] = [ 'scope' => 'storylet', 'type' => 'transition', 'target_id' => (string)$targetStoryletId, 'operation' => $operation, 'applied' => $applied, 'matched' => $matched, 'branch_index' => $selection['branch_index'] ?? null, 'error' => '', ]; continue; } $report = wayfarerApplyRuntimeOneTimeEffect( $runtimeState, $runtimeEffect, $regionId, $traitDefinitions ); if (!empty($report['error'])) { throw new RuntimeException( 'Storylet Effect failed: ' . (string)$report['error'] ); } $reports[] = $report; /* WAYFARER_STATUS_STORYLET_ACTIVATION_V92 */ if ( strtolower(trim((string)($report['type'] ?? ''))) === 'status' && !empty($report['applied']) ) { $statusActivationReports = strtolower(trim((string)($report['scope'] ?? ''))) === 'party' && isset($report['results']) && is_array($report['results']) ? array_values($report['results']) : [$report]; foreach ($statusActivationReports as $statusActivationReport) { if ( !is_array($statusActivationReport) || empty($statusActivationReport['applied']) || (int)($statusActivationReport['before'] ?? 0) !== 0 || (int)($statusActivationReport['after'] ?? 0) !== 1 ) { continue; } $activation = wayfarerRuntimeInitializeActivatedStatus( $pdo, $gameId, $runtimeState, (string)($statusActivationReport['scope'] ?? ''), (int)($statusActivationReport['target_id'] ?? 0), $regionId, $traitDefinitions ); if (($activation['error'] ?? '') !== '') { throw new RuntimeException( 'Storylet Status activation failed: ' . (string)$activation['error'] ); } foreach ( isset($activation['entry_effects']) && is_array($activation['entry_effects']) ? $activation['entry_effects'] : [] as $entryReport ) { if (!is_array($entryReport)) { continue; } if (!empty($entryReport['error'])) { throw new RuntimeException( 'Storylet Status On Enter Effect failed: ' . (string)$entryReport['error'] ); } $entryReport['status_activation_entry_effect'] = true; $reports[] = $entryReport; } } } } /* WAYFARER_STORYLET_EFFECT_FEEDBACK_V70 */ if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_storylet_effects'] = [ 'storylet_id' => (int)( $storylet['id'] ?? 0 ), 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), 'reports' => $reports, 'changes' => wayfarerRuntimeBuildEffectChangeEvents( $pdo, $gameId, $runtimeState, $reports ), ]; return $reports; } function wayfarerRuntimeIncrementStoryletOccurrence( array &$runtimeState, int $storyletId ): int { if ( !isset($runtimeState['storylets']) || !is_array($runtimeState['storylets']) ) { $runtimeState['storylets'] = []; } $key = (string)$storyletId; if ( !isset($runtimeState['storylets'][$key]) || !is_array($runtimeState['storylets'][$key]) ) { $runtimeState['storylets'][$key] = []; } $count = wayfarerRuntimeStoryletOccurrenceCount( $runtimeState, $storyletId ) + 1; $runtimeState['storylets'][$key]['occurrences'] = $count; $runtimeState['storylets'][$key]['last_entered_tick'] = (int)( $runtimeState['runtime']['tick'] ?? 0 ); return $count; } function wayfarerRuntimeEnterStorylet( PDO $pdo, int $gameUserId, int $storyletId, string $entryMode = 'action', int $expectedParentStoryletId = 0 ): array { $entryMode = strtolower( trim($entryMode) ); if ( $entryMode !== 'action' && $entryMode !== 'branch' ) { throw new RuntimeException( 'Invalid Storylet entry mode.' ); } try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $gameId = (int)$row['game_id']; $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } if ( isset($state['runtime']['ending']) && is_array($state['runtime']['ending']) ) { throw new RuntimeException( 'This game has ended.' ); } $activePresentation = $state['runtime']['presentation'] ?? null; $storylet = wayfarerRuntimeLoadStorylet( $pdo, $gameId, $storyletId ); if (!$storylet) { throw new RuntimeException( 'Storylet not found.' ); } if ($entryMode === 'action') { if (is_array($activePresentation)) { throw new RuntimeException( 'Continue the current presentation before choosing an Action.' ); } if ( !empty($storylet['parent_storylet_id']) || strtolower( trim( (string)( $storylet['trigger_type'] ?? '' ) ) ) !== 'manual' ) { throw new RuntimeException( 'That Storylet is not an available root Manual Action.' ); } } else { if ( !is_array($activePresentation) || (string)( $activePresentation['type'] ?? '' ) !== 'storylet' || (int)( $activePresentation['id'] ?? 0 ) <= 0 ) { throw new RuntimeException( 'There is no active Storylet Branch to choose from.' ); } $parentStoryletId = (int)( $activePresentation['id'] ?? 0 ); if ( $expectedParentStoryletId > 0 && $parentStoryletId !== $expectedParentStoryletId ) { throw new RuntimeException( 'The active Storylet changed before the Branch was selected.' ); } if ( (int)( $storylet['parent_storylet_id'] ?? 0 ) !== $parentStoryletId ) { throw new RuntimeException( 'That Storylet is not a Branch of the active Storylet.' ); } } wayfarerRuntimeEnsureStoryletTargetState( $pdo, $gameId, $state ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $state ) || ( $entryMode === 'action' && !wayfarerRuntimeStoryletBindingsAvailable( $pdo, $gameId, $storylet, $state ) ) ) { throw new RuntimeException( 'That Storylet is no longer eligible.' ); } $config = $storylet['_runtime_config'] ?? []; $tickCost = max( 0, (int)( $config['tick_cost'] ?? 0 ) ); /* * V59 proves interactive Storylets before refactoring the normal tick * pipeline into a reusable "consume N ticks" helper. Reject non-zero * cost rather than silently ignoring authored time passage. */ if ($tickCost > 0) { throw new RuntimeException( 'Storylet Tick Cost runtime is not implemented yet. Set Tick Cost to 0 for V59.' ); } /* WAYFARER_STORYLET_BINDING_RUNTIME_V79 */ /* WAYFARER_PERSISTENT_STORYLET_SUBJECT_V88 */ $presetBindings = []; if ( $entryMode === 'branch' && isset($activePresentation['bindings']['subject']) && is_array($activePresentation['bindings']['subject']) ) { $subjectScope = (string)( $activePresentation['bindings']['subject']['runtime_scope'] ?? '' ); $subjectRefresh = wayfarerRuntimeStoryletPresetCharacterBinding( $pdo, $gameId, $storylet, $state, $subjectScope, 'subject', (string)( $activePresentation['bindings']['subject']['label'] ?? 'Subject' ) ); if (($subjectRefresh['error'] ?? '') !== '') { throw new RuntimeException( (string)$subjectRefresh['error'] ); } if (is_array($subjectRefresh['binding'] ?? null)) { $presetBindings['subject'] = $subjectRefresh['binding']; } } $bindingResult = wayfarerRuntimeResolveStoryletBindings( $pdo, $gameId, $storylet, $state, $presetBindings ); if ( ($bindingResult['error'] ?? '') !== '' ) { throw new RuntimeException( (string)$bindingResult['error'] ); } $resolvedBindings = is_array( $bindingResult['bindings'] ?? null ) ? $bindingResult['bindings'] : []; $effects = wayfarerRuntimeApplyStoryletEffects( $pdo, $gameId, $storylet, $state, $resolvedBindings ); $occurrence = wayfarerRuntimeIncrementStoryletOccurrence( $state, $storyletId ); if ($entryMode === 'branch') { $state['runtime']['last_presentation'] = $activePresentation; } $presentationStorylet = $storylet; $presentationBindings = $resolvedBindings; $presentationOccurrence = $occurrence; $presentationTrigger = $entryMode; $transitionId = wayfarerRuntimeConsumeStoryletTransition( $state ); if ($transitionId > 0) { $linkedResult = wayfarerRuntimeEnterLinkedRootStorylet( $pdo, $gameId, $transitionId, $state, [ (string)$storyletId => true, ], isset($resolvedBindings['subject']) && is_array($resolvedBindings['subject']) ? [ 'subject' => $resolvedBindings['subject'], ] : [] ); $presentationStorylet = $linkedResult['storylet']; $presentationBindings = $linkedResult['bindings']; $presentationOccurrence = $linkedResult['occurrence']; $presentationTrigger = 'transition'; $state['runtime']['last_storylet_transition'] = [ 'from_storylet_id' => $storyletId, 'to_storylet_id' => (int)( $presentationStorylet['id'] ?? 0 ), 'chain' => $linkedResult['chain'], 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } else { unset( $state['runtime']['last_storylet_transition'] ); } $presentationStoryletId = (int)( $presentationStorylet['id'] ?? 0 ); $state['runtime']['presentation'] = [ 'type' => 'storylet', 'id' => $presentationStoryletId, 'phase' => 'presentation', 'trigger' => $presentationTrigger, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), 'bindings' => $presentationBindings, ]; $state['runtime']['last_storylet_entry'] = [ 'storylet_id' => $presentationStoryletId, 'parent_storylet_id' => (int)( $presentationStorylet['parent_storylet_id'] ?? 0 ), 'entry_mode' => $presentationTrigger, 'occurrence' => $presentationOccurrence, 'bindings' => $presentationBindings, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; $ending = wayfarerRuntimeEvaluateGameEndings( $pdo, $gameId, $state ); $encoded = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encoded, $gameUserId, ]); $pdo->commit(); $presentation = $state['runtime']['presentation'] ?? null; return [ 'game_user_id' => (int)$row['id'], 'game_id' => $gameId, 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, $gameId, $state ), 'presentation' => is_array($presentation) ? $presentation : null, 'ending' => $ending, 'effects' => $effects, 'occurrence' => $presentationOccurrence, 'actions' => $ending === null && !is_array($presentation) ? wayfarerRuntimeEligibleRootManualStorylets( $pdo, $gameId, $state ) : [], ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: _update_backups/v103-20260930-212529/assets/js/storylet-tokens.js ================================================================================ /* WAYFARER_STORYLET_TOKEN_PICKER_V101 */ (function () { 'use strict'; const picker = document.querySelector('[data-storylet-token-picker]'); const textarea = document.querySelector('[data-storylet-narrative-field]'); if (!picker || !textarea) { return; } const toggle = picker.querySelector('[data-storylet-token-toggle]'); const menu = picker.querySelector('[data-storylet-token-menu]'); const bindingOptions = picker.querySelector('[data-storylet-binding-token-options]'); if (!toggle || !menu || !bindingOptions) { return; } function normalizeKey(value) { return String(value || '') .trim() .toLowerCase() .replace(/[^a-z0-9_]/g, ''); } function bindingRows() { return Array.from( document.querySelectorAll( '[data-storylet-bindings-editor] [data-storylet-binding-row]' ) ); } function refreshBindingTokens() { bindingOptions.innerHTML = ''; const seen = new Set(); bindingRows().forEach(function (row) { const keyInput = row.querySelector('[data-storylet-binding-key]'); const labelInput = row.querySelector('[data-storylet-binding-label]'); const key = normalizeKey(keyInput ? keyInput.value : ''); if (!key || key === 'subject' || seen.has(key)) { return; } seen.add(key); const authorLabel = String( labelInput && labelInput.value ? labelInput.value : key.replace(/_/g, ' ') ).trim(); const token = '{{' + key + '.name}}'; const button = document.createElement('button'); button.type = 'button'; button.className = 'wayfarer-storylet-token-option'; button.dataset.storyletToken = token; const label = document.createElement('span'); label.textContent = authorLabel !== '' ? authorLabel + ' Name' : 'Binding Name'; const code = document.createElement('code'); code.textContent = token; button.appendChild(label); button.appendChild(code); bindingOptions.appendChild(button); }); if (bindingOptions.children.length === 0) { const empty = document.createElement('small'); empty.className = 'wayfarer-storylet-token-empty'; empty.textContent = 'No named Storylet Bindings are currently available.'; bindingOptions.appendChild(empty); } } function setOpen(open) { menu.hidden = !open; toggle.setAttribute('aria-expanded', open ? 'true' : 'false'); if (open) { refreshBindingTokens(); } } function insertToken(token) { const start = Number.isInteger(textarea.selectionStart) ? textarea.selectionStart : textarea.value.length; const end = Number.isInteger(textarea.selectionEnd) ? textarea.selectionEnd : start; textarea.value = textarea.value.slice(0, start) + token + textarea.value.slice(end); const cursor = start + token.length; textarea.focus(); textarea.setSelectionRange(cursor, cursor); textarea.dispatchEvent(new Event('input', { bubbles: true })); } toggle.addEventListener('click', function () { setOpen(menu.hidden); }); picker.addEventListener('click', function (event) { const button = event.target.closest('[data-storylet-token]'); if (!button) { return; } const token = String(button.dataset.storyletToken || ''); if (token === '') { return; } insertToken(token); setOpen(false); }); document.addEventListener('input', function (event) { if ( event.target.matches('[data-storylet-binding-key]') || event.target.matches('[data-storylet-binding-label]') ) { if (!menu.hidden) { refreshBindingTokens(); } } }); document.addEventListener('click', function (event) { if ( !menu.hidden && !picker.contains(event.target) ) { setOpen(false); } }); document.addEventListener('keydown', function (event) { if (event.key === 'Escape' && !menu.hidden) { setOpen(false); toggle.focus(); } }); refreshBindingTokens(); })(); ================================================================================ FILE: _update_backups/v103-20260930-212529/includes/builder/storylet-gameplay.php ================================================================================

Bindings

A Binding selects one runtime subject when this Storylet begins and keeps that same selection for the entire Storylet occurrence. Effects and narrative tokens can then refer to the selected subject consistently.

Binding Name is the author-facing name shown elsewhere in the builder. Key is the internal identifier used by narrative tokens such as {{victim.name}}; Wayfarer normalizes it to lowercase letters, numbers, and underscores. Selection Rule decides what is selected. Random Party Member chooses from both named Player Characters and recruited Character Entities currently in the party; if an Effect uses a Trait such as Health, only party members that expose that Trait are eligible. Random Possessed Item chooses from Items whose current Quantity is greater than 0.

$bindingRow ): ?>
Narrative Token:

Conditions

Conditions determine whether this Storylet is eligible. A root Manual Storylet can be offered only while these are true. Automatic and Chance Storylets can fire only while eligible. For a child Storylet, these same Conditions determine whether its Branch is available to the player.

Effects

These Effects run once when the player enters this Storylet. Each Effect may use ordered conditional results and an optional Otherwise result. Targets come from Wayfarer's universal runtime-state catalog, so the Storylet may inspect or change Game, Player, Region, Location, Item, and Character Trait state. Definition metadata such as min/max is not a target.

Branches

A Branch is another Storylet whose Parent Storylet is this Storylet. The child Storylet's Conditions decide whether the Branch is available; entering that child runs its Effects. Children may have their own Branches, so this structure can nest to any practical depth without a separate branch-object schema.

Save this Storylet first. After it has an ID, you can add child Branch Storylets here.

prepare( " SELECT id, name, branch_label, branch_sort_order, trigger_type, status FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? ORDER BY branch_sort_order, name, id " ); $branchStmt->execute([ $gameId, (int)$id, ]); $storyletBranches = $branchStmt->fetchAll( PDO::FETCH_ASSOC ); ?>

+ New Branch Storylet

No child Branches have been created yet.

Order Branch Storylet Trigger Status
================================================================================ FILE: _update_backups/v103-20260930-212529/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', /* WAYFARER_HUD_TARGET_BOUNDS_V94 */ 'min' => $dataType === 'number' && isset($trait['min']) && is_numeric($trait['min']) ? (float)$trait['min'] : null, 'max' => $dataType === 'number' && isset($trait['max']) && is_numeric($trait['max']) ? (float)$trait['max'] : null, 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* |-------------------------------------------------------------------------- | Party-member Trait catalog | WAYFARER_PARTY_TARGETS_V85 |-------------------------------------------------------------------------- | | Player Traits and authored Character Entity Traits may represent the same | logical per-person property (for example, Health). Party-level authoring | addresses that property by normalized name so one Effect can operate across | the current party without exposing runtime player-slot numbers. | */ function gameplayPartyTraitNormalizeName( string $value ): string { $value = trim( preg_replace( '/\\s+/u', ' ', $value ) ?? $value ); if (function_exists('mb_strtolower')) { return mb_strtolower( $value, 'UTF-8' ); } return strtolower($value); } function gameplayPartyTraitTargetId( string $traitName ): string { return 'party_name_' . bin2hex( gameplayPartyTraitNormalizeName( $traitName ) ); } function gameplayPartyTraitCatalog( PDO $pdo, int $gameId, ?array $playerTraitsOverride = null ): array { if ($gameId <= 0) { return []; } $catalog = []; $append = static function ( array &$catalog, array $trait ): void { if ( !is_array($trait) || !empty($trait['system']) || ( array_key_exists('active', $trait) && !$trait['active'] ) ) { return; } $name = trim( (string)( $trait['name'] ?? '' ) ); if ($name === '') { return; } $type = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); if ($type === 'binary') { $type = 'boolean'; } if ( !in_array( $type, ['number', 'boolean'], true ) ) { return; } $normalized = gameplayPartyTraitNormalizeName( $name ); if ($normalized === '') { return; } if (!isset($catalog[$normalized])) { $catalog[$normalized] = [ 'name' => $name, 'data_type' => $type, 'conflict' => false, ]; return; } if ( (string)$catalog[$normalized]['data_type'] !== $type ) { $catalog[$normalized]['conflict'] = true; } }; if ($playerTraitsOverride === null) { $stmt = $pdo->prepare( 'SELECT config FROM wf_games WHERE id = ? LIMIT 1' ); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); $config = $raw === false ? [] : gameplayRuntimeStateDecodeConfig( $raw ); $playerTraits = isset($config['player_traits']) && is_array($config['player_traits']) ? $config['player_traits'] : []; } else { $playerTraits = $playerTraitsOverride; } foreach ($playerTraits as $trait) { if (is_array($trait)) { $append( $catalog, $trait ); } } $stmt = $pdo->prepare( " SELECT config FROM wf_entities WHERE game_id = ? AND status = 'active' AND LOWER(entity_type) = 'character' ORDER BY id " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (is_array($trait)) { $append( $catalog, $trait ); } } } foreach ($catalog as $normalized => $definition) { if (!empty($definition['conflict'])) { unset($catalog[$normalized]); } } uasort( $catalog, static function ( array $a, array $b ): int { return strcasecmp( (string)$a['name'], (string)$b['name'] ); } ); return $catalog; } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } /* |-------------------------------------------------------------------------- | Status definitions / target metadata | WAYFARER_STATUSES_AUTHORING_V87 | WAYFARER_STATUS_OWNERS_V92 |-------------------------------------------------------------------------- */ function gameplayStatusDefinitions( PDO $pdo, int $gameId, bool $activeOnly = true ): array { if ($gameId <= 0) { return []; } $sql = " SELECT id, name, description, config, status FROM wf_statuses WHERE game_id = ? "; if ($activeOnly) { $sql .= " AND status = 'active'"; } $sql .= " ORDER BY name, id"; $stmt = $pdo->prepare($sql); $stmt->execute([$gameId]); $definitions = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $normalizeBoolean = static function ($value, bool $default): bool { if ($value === null || $value === '') { return $default; } if (is_bool($value)) { return $value; } return in_array( strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true ); }; $definitions[(string)$id] = [ 'id' => $id, 'name' => trim((string)($row['name'] ?? ('Status ' . $id))), 'description' => trim((string)($row['description'] ?? '')), 'status' => strtolower(trim((string)($row['status'] ?? 'draft'))), /* WAYFARER_STATUS_OWNERS_V92 */ 'applies_to' => in_array( strtolower(trim((string)($config['applies_to'] ?? 'character'))), ['character', 'item', 'both'], true ) ? strtolower(trim((string)($config['applies_to'] ?? 'character'))) : 'character', 'show_to_player' => $normalizeBoolean( $config['show_to_player'] ?? null, true ), /* WAYFARER_STATUS_LIFECYCLE_AUTHORING_V88 */ 'show_stage_to_player' => $normalizeBoolean( $config['show_stage_to_player'] ?? null, true ), 'show_time_remaining' => $normalizeBoolean( $config['show_time_remaining'] ?? null, false ), 'lifecycle' => isset($config['lifecycle']) && is_array($config['lifecycle']) ? $config['lifecycle'] : null, ]; } return $definitions; } /* WAYFARER_STATUS_OWNERS_V92 */ function gameplayStatusAppliesTo( array $definition, string $ownerType ): bool { $appliesTo = strtolower( trim((string)($definition['applies_to'] ?? 'character')) ); $ownerType = strtolower(trim($ownerType)); if ($appliesTo === 'both') { return in_array($ownerType, ['character', 'item'], true); } return $appliesTo === $ownerType; } function gameplayStatusTargetDefinition( string $scope, int $statusId, string $statusName, array $hierarchy, array $extra = [] ): array { return array_merge( [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'status', 'target_id' => (string)$statusId, 'trait_type' => 'boolean', 'data_type' => 'boolean', 'semantic_type' => 'status', 'helper' => 'Persistent Status: Active / Inactive', 'condition_operators' => [ 'equals', 'not_equals', ], 'effect_operations' => [ 'set', ], 'supports_random' => false, 'value_options' => [ '0' => 'Inactive', '1' => 'Active', ], 'group' => 'Statuses', 'label' => $statusName, 'hierarchy' => $hierarchy, ], $extra ); } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } /* WAYFARER_PARTY_TARGETS_V85 */ foreach ( gameplayPartyTraitCatalog( $pdo, $gameId, $playerTraits ) as $partyTrait ) { $partyName = (string)( $partyTrait['name'] ?? '' ); $partyDataType = (string)( $partyTrait['data_type'] ?? 'text' ); if ( $partyName === '' || !in_array( $partyDataType, ['number', 'boolean'], true ) ) { continue; } $partyTargetId = gameplayPartyTraitTargetId( $partyName ); $partyKey = 'party:trait:' . $partyTargetId; $targets[$partyKey] = [ 'scope' => 'party', 'target_scope' => 'party', 'target_type' => 'trait', 'target_id' => $partyTargetId, 'trait_type' => $partyDataType, 'data_type' => $partyDataType, 'semantic_type' => 'party_all_trait', 'helper' => 'Every current party member that exposes this Trait', 'condition_operators' => $partyDataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ], 'effect_operations' => $partyDataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ], 'supports_random' => $partyDataType === 'number', /* * A Party Condition needs an explicit Any/All quantifier model. * Until that exists, this aggregate target is Effect-only. */ 'condition_hidden' => true, 'group' => 'Party', 'label' => 'All Party Members → ' . $partyName, 'hierarchy' => [ [ 'key' => 'party', 'label' => 'Party', ], [ 'key' => 'all_party_members', 'label' => 'All Party Members', ], [ 'key' => $partyTargetId, 'label' => $partyName, ], ], ]; if ($partyDataType === 'boolean') { $targets[$partyKey]['value_options'] = [ '0' => 'False', '1' => 'True', ]; } } /* WAYFARER_STATUS_TARGETS_V87 */ $statusDefinitions = gameplayStatusDefinitions( $pdo, $gameId, true ); foreach ($statusDefinitions as $statusDefinition) { $statusId = (int)($statusDefinition['id'] ?? 0); $statusName = trim((string)($statusDefinition['name'] ?? '')); if ( $statusId <= 0 || $statusName === '' || !gameplayStatusAppliesTo($statusDefinition, 'character') ) { continue; } $partyStatusKey = 'party:status:' . $statusId; $targets[$partyStatusKey] = gameplayStatusTargetDefinition( 'party', $statusId, $statusName, [ [ 'key' => 'party', 'label' => 'Party', ], [ 'key' => 'all_party_members', 'label' => 'All Party Members', ], [ 'key' => 'status:' . $statusId, 'label' => 'Status — ' . $statusName, ], ], [ /* Party Status Conditions need explicit Any/All semantics. */ 'condition_hidden' => true, 'group' => 'Party', 'label' => 'All Party Members → Status — ' . $statusName, ] ); for ($slot = 0; $slot < $playerSlots; $slot++) { $playerStatusKey = 'player:' . $slot . ':status:' . $statusId; $targets[$playerStatusKey] = gameplayStatusTargetDefinition( 'player:' . $slot, $statusId, $statusName, [ [ 'key' => 'player', 'label' => 'Player Character', ], [ 'key' => 'player:' . $slot, 'label' => 'Player ' . ($slot + 1), ], [ 'key' => 'status:' . $statusId, 'label' => 'Status — ' . $statusName, ], ], [ /* Player slots remain a runtime compatibility address. */ 'condition_hidden' => true, 'effect_hidden' => true, ] ); } } /* * Numbered Player slots remain runtime-compatible for existing authored * data, but they are implementation slots rather than useful new targets. * New authoring uses Storylet Binding -> Random Party Member or * Party -> All Party Members instead. */ for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); $playerKey = 'player:' . $slot . ':trait:' . $traitId; if ( $traitId !== '' && isset($targets[$playerKey]) ) { $targets[$playerKey]['condition_hidden'] = true; $targets[$playerKey]['effect_hidden'] = true; } } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'min' => 0, /* WAYFARER_HUD_ITEM_QUANTITY_BOUNDS_V94 */ 'max' => isset($config['system']['quantity']['max']) && is_numeric($config['system']['quantity']['max']) ? (float)$config['system']['quantity']['max'] : null, 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } /* WAYFARER_STATUS_OWNERS_V92 */ if (in_array($entityType, ['character', 'item'], true)) { foreach ($statusDefinitions as $statusDefinition) { $statusId = (int)($statusDefinition['id'] ?? 0); $statusName = trim((string)($statusDefinition['name'] ?? '')); if ( $statusId <= 0 || $statusName === '' || !gameplayStatusAppliesTo($statusDefinition, $entityType) ) { continue; } $statusKey = 'entity:' . $id . ':status:' . $statusId; $ownerLabel = $entityType === 'item' ? 'Item' : 'Character'; $groupLabel = $entityType === 'item' ? 'Items' : 'Characters'; $targets[$statusKey] = gameplayStatusTargetDefinition( 'entity:' . $id, $statusId, $statusName, [ [ 'key' => $entityType, 'label' => $ownerLabel, ], [ 'key' => 'entity:' . $id, 'label' => $name, ], [ 'key' => 'status:' . $statusId, 'label' => 'Status — ' . $statusName, ], ], [ 'group' => $groupLabel, 'label' => $name . ' → Status — ' . $statusName, ] ); } } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ /* |-------------------------------------------------------------------------- | Navigation Condition Targets |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITION_TARGETS_V75 | WAYFARER_LIVE_LOCATION_ANCHORS_V76 | | Numeric Position Conditions are Region-qualified. | | New Location Conditions reference wf_locations.id as target_id. The | comparison itself is against zero because runtime resolves the target to: | | current position - current anchor position | | That makes a Location a live spatial anchor: | | = 0 => At | != 0 => Not at | < 0 => Before | <= 0 => At or before | > 0 => After | >= 0 => At or after | | The V75 snapshot target navigation:location:location remains hidden for | backward compatibility only. | */ function gameplayConditionNavigationPositionToken( $value ): string { if (!is_numeric($value)) { return ''; } $number = (float)$value; if (is_finite($number) && floor($number) == $number) { return (string)((int)$number); } $token = rtrim( rtrim( sprintf('%.12F', $number), '0' ), '.' ); return $token === '-0' ? '0' : $token; } function gameplayNavigationConditionTargets( PDO $pdo, int $gameId ): array { $targets = []; /* * Compatibility only. Existing saved unqualified Position Conditions * still render and save, but cannot be newly authored. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position_legacy', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'group' => 'Navigation', 'label' => 'Position (legacy unqualified)', 'condition_hidden' => true, ]; /* * Compatibility only. V75 stored Region + Position in the value itself. * New V76 Conditions never use this target. */ $targets['navigation:location:location'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => 'location', 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'navigation_location_v75_snapshot', 'helper' => 'V75 snapshot', 'condition_operators' => [ 'equals', 'not_equals', ], 'group' => 'Navigation', 'label' => 'Location (legacy V75 snapshot)', 'condition_hidden' => true, ]; if ($gameId <= 0) { return $targets; } /* * Fixed numeric Position, explicitly qualified to one linear Region. */ $regionStmt = $pdo->prepare( " SELECT id, name, navigation_type FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $regionStmt->execute([ $gameId ]); foreach ( $regionStmt->fetchAll(PDO::FETCH_ASSOC) as $region ) { $regionId = (int)($region['id'] ?? 0); if ($regionId <= 0) { continue; } if ( strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) !== 'linear' ) { continue; } $regionName = trim( (string)( $region['name'] ?? ('Region ' . $regionId) ) ); $scope = 'navigation:' . $regionId; $key = $scope . ':position:position'; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_region_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'group' => 'Navigation — Positions', 'label' => $regionName, ]; } /* * Named Locations are LIVE references. Each Location is its own target, * identified by wf_locations.id. Position is shown only as authoring help; * it is resolved again from current Location config at runtime. */ $locationStmt = $pdo->prepare( " SELECT l.id, l.name, l.config, l.region_id, r.name AS region_name, r.navigation_type FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $locationStmt->execute([ $gameId ]); foreach ( $locationStmt->fetchAll(PDO::FETCH_ASSOC) as $location ) { if ( strtolower( trim( (string)( $location['navigation_type'] ?? '' ) ) ) !== 'linear' ) { continue; } $locationId = (int)( $location['id'] ?? 0 ); if ($locationId <= 0) { continue; } $config = gameplayRuntimeStateDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $position = gameplayConditionNavigationPositionToken( $config['position'] ); if ($position === '') { continue; } $locationName = trim( (string)( $location['name'] ?? ('Location ' . $locationId) ) ); $regionName = trim( (string)( $location['region_name'] ?? 'Region' ) ); $key = 'navigation:location:' . $locationId; $targets[$key] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => (string)$locationId, 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_location_anchor', 'helper' => $regionName . ' · Position ' . $position, 'condition_operators' => [ 'equals', 'not_equals', 'less_than', 'less_than_or_equal', 'greater_than', 'greater_than_or_equal', ], 'condition_operator_labels' => [ 'equals' => 'At', 'not_equals' => 'Not at', 'less_than' => 'Before', 'less_than_or_equal' => 'At or before', 'greater_than' => 'After', 'greater_than_or_equal' => 'At or after', ], /* * The runtime value is a signed delta from this anchor, so every * comparison is against zero. Keep that implementation detail out * of the authoring UI while still posting a normal Condition value. */ 'value_options' => [ '0' => 'Anchor', ], 'condition_hide_value' => true, 'group' => 'Navigation — Locations', 'label' => $locationName, ]; } return $targets; } /* * Allows Condition POST validation to recognize V76 navigation-only target * shapes even when a caller supplied the older universal Effect catalog. */ /* |-------------------------------------------------------------------------- | Storylet Navigation Effect targets | WAYFARER_STORYLET_ROOT_CONNECTIONS_V82 |-------------------------------------------------------------------------- | | Explicit Storylet navigation is an Effect-only target. A Storylet may | continue directly to another root Storylet after its other Effects resolve. | Child Storylets remain structural Branches and are deliberately not exposed | as destinations here. */ function gameplayStoryletNavigationEffectTargets( PDO $pdo, int $gameId, int $currentStoryletId = 0 ): array { $targets = []; if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, status FROM wf_storylets WHERE game_id = ? AND ( parent_storylet_id IS NULL OR parent_storylet_id = 0 ) ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $storylet ) { $storyletId = (int)( $storylet['id'] ?? 0 ); if ( $storyletId <= 0 || ( $currentStoryletId > 0 && $storyletId === $currentStoryletId ) ) { continue; } $name = trim( (string)( $storylet['name'] ?? '' ) ); if ($name === '') { $name = 'Storylet ' . $storyletId; } $status = strtolower( trim( (string)( $storylet['status'] ?? '' ) ) ); $helper = $status !== '' ? ucfirst($status) . ' root Storylet' : 'Root Storylet'; $key = 'storylet:transition:' . $storyletId; $targets[$key] = [ 'scope' => 'storylet', 'target_scope' => 'storylet', 'target_type' => 'transition', 'target_id' => (string)$storyletId, 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'storylet_transition', 'helper' => $helper, 'effect_operations' => [ 'continue', ], 'supports_random' => false, 'group' => 'Storylet Navigation', 'label' => $name . ' → Continue To', 'hierarchy' => [ [ 'key' => 'storylet', 'label' => 'Storylet', ], [ 'key' => 'storylet:' . $storyletId, 'label' => $name, ], [ 'key' => 'continue_to', 'label' => 'Continue To', ], ], ]; } return $targets; } /* |-------------------------------------------------------------------------- | Hierarchical target-ready Time Conditions | WAYFARER_HIERARCHICAL_TARGETS_V80 |-------------------------------------------------------------------------- | | The hierarchy UI consumes a generic target catalog. New systems can opt in | simply by supplying a three-level hierarchy array: | | Type -> Specific Target -> Property | | Current Game Time is Condition-only. Effects do not modify the clock here. | Calendar / Seasons can later register additional Time properties without | changing the picker. */ function gameplayTimeConditionTargets( PDO $pdo, int $gameId ): array { $targets = []; if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ($raw === false) { return $targets; } $config = gameplayRuntimeStateDecodeConfig( $raw ); $time = isset($config['time']) && is_array($config['time']) ? $config['time'] : []; $mode = strtolower( trim( (string)( $time['mode'] ?? 'none' ) ) ); if ( $mode !== 'days' && $mode !== 'hours' ) { return $targets; } $base = [ 'scope' => 'time', 'target_scope' => 'time', 'target_type' => 'clock', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'game_time', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'group' => 'Time', ]; $targets['time:clock:day'] = array_merge( $base, [ 'target_id' => 'day', 'label' => 'Game Clock → Day', 'hierarchy' => [ [ 'key' => 'time', 'label' => 'Time', ], [ 'key' => 'game_clock', 'label' => 'Game Clock', ], [ 'key' => 'day', 'label' => 'Day', ], ], ] ); if ($mode === 'hours') { $targets['time:clock:hour'] = array_merge( $base, [ 'target_id' => 'hour', 'label' => 'Game Clock → Hour', 'hierarchy' => [ [ 'key' => 'time', 'label' => 'Time', ], [ 'key' => 'game_clock', 'label' => 'Game Clock', ], [ 'key' => 'hour', 'label' => 'Hour', ], ], ] ); } return $targets; } function gameplaySyntheticTimeConditionTarget( string $target ): ?array { if ($target === 'time:clock:day') { return [ 'scope' => 'time', 'target_scope' => 'time', 'target_type' => 'clock', 'target_id' => 'day', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'game_time', 'condition_operators' => wayfarerConditionOperators(), ]; } if ($target === 'time:clock:hour') { return [ 'scope' => 'time', 'target_scope' => 'time', 'target_type' => 'clock', 'target_id' => 'hour', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'game_time', 'condition_operators' => wayfarerConditionOperators(), ]; } return null; } function gameplaySyntheticNavigationConditionTarget( string $target ): ?array { if ( preg_match( '/^navigation:([1-9][0-9]*):position:position$/', $target, $match ) === 1 ) { $scope = 'navigation:' . (int)$match[1]; return [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_region_position', 'condition_operators' => wayfarerConditionOperators(), ]; } if ( preg_match( '/^navigation:location:([1-9][0-9]*)$/', $target, $match ) === 1 ) { return [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => (string)((int)$match[1]), 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_location_anchor', 'condition_operators' => [ 'equals', 'not_equals', 'less_than', 'less_than_or_equal', 'greater_than', 'greater_than_or_equal', ], ]; } /* V75 snapshot compatibility. */ if ($target === 'navigation:location:location') { return [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => 'location', 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'navigation_location_v75_snapshot', 'condition_operators' => [ 'equals', 'not_equals', ], ]; } return null; } function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return array_replace( gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ), gameplayNavigationConditionTargets( $pdo, $gameId ), gameplayTimeConditionTargets( $pdo, $gameId ) ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; /* * WAYFARER_LOCATION_CONDITION_TARGETS_V75 * * Older callers may still pass the universal Effect target catalog. * Region-qualified Position and Location targets are Condition-only, * so synthesize their definitions here when needed. */ if ( $target !== '' && !isset($allowedTargets[$target]) ) { $syntheticTarget = gameplaySyntheticNavigationConditionTarget( $target ); if ( $syntheticTarget === null && function_exists( 'gameplaySyntheticTimeConditionTarget' ) ) { $syntheticTarget = gameplaySyntheticTimeConditionTarget( $target ); } if ($syntheticTarget !== null) { $allowedTargets[$target] = $syntheticTarget; } } if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: _update_backups/v103-20260930-212529/play/includes/presentation-screen.php ================================================================================ prepare( " SELECT l.*, r.game_id, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE l.id = ? AND r.game_id = ? LIMIT 1 " ); $stmt->execute([ $presentationId, (int)$gameId, ]); $presentationRecord = $stmt->fetch( PDO::FETCH_ASSOC ); if ($presentationRecord) { /* * Media links use the authored table name as object_type. */ $mediaStmt = $pdo->prepare( " SELECT m.id, m.name, m.relative_path, m.mime_type FROM wf_media_links ml INNER JOIN wf_media m ON m.id = ml.media_id WHERE ml.object_type = 'wf_locations' AND ml.object_id = ? AND ml.slot = 'image' AND m.game_id = ? AND m.media_type = 'image' LIMIT 1 " ); $mediaStmt->execute([ $presentationId, (int)$gameId, ]); $presentationImage = $mediaStmt->fetch( PDO::FETCH_ASSOC ); } } /* * --------------------------------------------------------------- * STORYLET * WAYFARER_STORYLET_PRESENTATION_V59 * --------------------------------------------------------------- */ if ( $presentationType === 'storylet' ) { $presentationRecord = wayfarerRuntimeLoadStorylet( $pdo, (int)$gameId, $presentationId ); if ($presentationRecord) { $mediaStmt = $pdo->prepare( " SELECT m.id, m.name, m.relative_path, m.mime_type FROM wf_media_links ml INNER JOIN wf_media m ON m.id = ml.media_id WHERE ml.object_type = 'wf_storylets' AND ml.object_id = ? AND ml.slot = 'image' AND m.game_id = ? AND m.media_type = 'image' LIMIT 1 " ); $mediaStmt->execute([ $presentationId, (int)$gameId, ]); $presentationImage = $mediaStmt->fetch( PDO::FETCH_ASSOC ); /* * Conditions are evaluated AFTER the current Storylet's Effects have * executed, because entry persisted the mutated state first. */ $presentationBranches = wayfarerRuntimeEligibleStoryletBranches( $pdo, (int)$gameId, $presentationId, $state ); } } if (!$presentationRecord) { /* * Keep the player from becoming trapped if an authored object is removed * after its presentation state was persisted. */ $presentationRecord = [ 'name' => 'Continue', 'description' => 'This presentation is no longer available.', ]; } $presentationName = trim( (string)( $presentationRecord['name'] ?? '' ) ); if ($presentationName === '') { $presentationName = $presentationType === 'storylet' ? 'Storylet' : 'Location'; } $presentationDescription = trim( (string)( $presentationRecord['description'] ?? '' ) ); /* WAYFARER_STORYLET_BINDING_RUNTIME_V79 */ if ( $presentationType === 'storylet' && isset($presentation['bindings']) && is_array($presentation['bindings']) ) { $presentationName = wayfarerRuntimeInterpolateStoryletBindings( $presentationName, $presentation['bindings'] ); $presentationDescription = wayfarerRuntimeInterpolateStoryletBindings( $presentationDescription, $presentation['bindings'] ); } $presentationImageUrl = ''; if ( is_array($presentationImage) && !empty( $presentationImage['relative_path'] ) ) { $relativePath = ltrim( str_replace( '\\', '/', (string)$presentationImage[ 'relative_path' ] ), '/' ); /* * Media uploads are stored beneath the Wayfarer root. play/ is one level * below that root. */ if ( $relativePath !== '' && strpos( $relativePath, '..' ) === false ) { $presentationImageUrl = '../' . $relativePath; } } ?> ================================================================================ FILE: _update_backups/v103-20260930-212529/play/includes/runtime.php ================================================================================ 'location', 'location_id' => (int)($location['id'] ?? 0), 'region_id' => (int)($runtimeState['navigation']['region_id'] ?? 0), 'position' => $runtimeState['navigation']['position'] ?? null, 'tick' => (int)($runtimeState['runtime']['tick'] ?? 0), ]; unset( $runtimeState['runtime']['pending_storylet_source_context'] ); wayfarerRuntimeEnterTriggeredStoryletInState( $pdo, $gameId, $storyletId, $runtimeState, [], 'location' ); if ( isset($runtimeState['runtime']['presentation']) && is_array($runtimeState['runtime']['presentation']) && (string)($runtimeState['runtime']['presentation']['type'] ?? '') === 'storylet' ) { $runtimeState['runtime']['presentation']['source_context'] = $sourceContext; $runtimeState['runtime']['presentation']['location_trigger'] = $trigger; } if ( isset($runtimeState['runtime']['last_storylet_entry']) && is_array($runtimeState['runtime']['last_storylet_entry']) ) { $runtimeState['runtime']['last_storylet_entry']['source_context'] = $sourceContext; $runtimeState['runtime']['last_storylet_entry']['location_trigger'] = $trigger; } $runtimeState['runtime']['last_location_storylet_transition'] = [ 'location_id' => (int)($sourceContext['location_id'] ?? 0), 'storylet_id' => (int)($runtimeState['runtime']['presentation']['id'] ?? $storyletId), 'requested_storylet_id' => $storyletId, 'trigger' => $trigger, 'tick' => (int)($runtimeState['runtime']['tick'] ?? 0), 'source_context' => $sourceContext, ]; return true; } /* WAYFARER_RUNTIME_STARTING_SETUP_V36 */ /* WAYFARER_ENTITY_RUNTIME_STATE_V49 */ /* WAYFARER_PLAYER_TRAITS_RUNTIME_V52 */ /** * Minimal Wayfarer play-runtime helpers. * * v14 deliberately implements only a persisted tick counter. */ /** * Decode wf_game_users.state safely. */ function wayfarerRuntimeDecodeState($rawState): array { if (is_array($rawState)) { return $rawState; } if ($rawState === null || $rawState === '') { return []; } $decoded = json_decode( (string)$rawState, true ); return is_array($decoded) ? $decoded : []; } /** * Encode runtime state for the JSON column. */ function wayfarerRuntimeEncodeState(array $state): string { $json = json_encode( $state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ($json === false) { throw new RuntimeException( 'Unable to encode Wayfarer runtime state.' ); } return $json; } /* |-------------------------------------------------------------------------- | Canonical Runtime Initialization |-------------------------------------------------------------------------- | | WAYFARER_RUNTIME_INITIALIZATION_V31 | | New-game state is created here rather than piecemeal by the play page. | Future startup systems (inventory, entities, calendar, etc.) can extend this | single initializer. | */ function wayfarerRuntimeDecodeConfig( $rawConfig ): array { if (is_array($rawConfig)) { return $rawConfig; } if ( $rawConfig === null || $rawConfig === '' ) { return []; } $decoded = json_decode( (string)$rawConfig, true ); return is_array($decoded) ? $decoded : []; } /* |-------------------------------------------------------------------------- | Game clock + Linear travel HUD | WAYFARER_TRAVEL_TIME_HUD_V61 |-------------------------------------------------------------------------- */ function wayfarerRuntimeTimeConfig(array $gameConfig): array { $raw = isset($gameConfig['time']) && is_array($gameConfig['time']) ? $gameConfig['time'] : []; $mode = strtolower(trim((string)($raw['mode'] ?? 'none'))); if (!in_array($mode, ['none', 'days', 'hours'], true)) { $mode = 'none'; } $config = [ 'mode' => $mode, 'starting_day' => max(1, (int)($raw['starting_day'] ?? 1)), 'ticks_per_day' => max(1, (int)($raw['ticks_per_day'] ?? 1)), 'starting_hour' => max(0, (int)($raw['starting_hour'] ?? 8)), 'ticks_per_hour' => max(1, (int)($raw['ticks_per_hour'] ?? 1)), 'hours_per_day' => max(1, (int)($raw['hours_per_day'] ?? 24)), ]; if ($config['starting_hour'] >= $config['hours_per_day']) { $config['starting_hour'] = $config['hours_per_day'] - 1; } return $config; } function wayfarerRuntimeInitializeClockFromConfig( array $gameConfig, array &$runtimeState ): void { if (!isset($runtimeState['runtime']) || !is_array($runtimeState['runtime'])) { $runtimeState['runtime'] = []; } $config = wayfarerRuntimeTimeConfig($gameConfig); if ($config['mode'] === 'none') { unset($runtimeState['runtime']['clock']); return; } $clock = [ 'mode' => $config['mode'], 'day' => $config['starting_day'], ]; if ($config['mode'] === 'days') { $clock['tick_in_day'] = 0; } else { $clock['hour'] = $config['starting_hour']; $clock['tick_in_hour'] = 0; } $runtimeState['runtime']['clock'] = $clock; } function wayfarerRuntimeLoadGameConfig(PDO $pdo, int $gameId): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare('SELECT config FROM wf_games WHERE id = ? LIMIT 1'); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); return wayfarerRuntimeDecodeConfig($raw === false ? null : $raw); } function wayfarerRuntimeEnsureClock( PDO $pdo, int $gameId, array &$runtimeState ): array { $gameConfig = wayfarerRuntimeLoadGameConfig($pdo, $gameId); $timeConfig = wayfarerRuntimeTimeConfig($gameConfig); $clock = $runtimeState['runtime']['clock'] ?? null; if ($timeConfig['mode'] === 'none') { unset($runtimeState['runtime']['clock']); return $timeConfig; } if (!is_array($clock) || (string)($clock['mode'] ?? '') !== $timeConfig['mode']) { wayfarerRuntimeInitializeClockFromConfig($gameConfig, $runtimeState); } return $timeConfig; } function wayfarerRuntimeAdvanceClock( PDO $pdo, int $gameId, array &$runtimeState ): void { $config = wayfarerRuntimeEnsureClock($pdo, $gameId, $runtimeState); if ($config['mode'] === 'none') { return; } $clock =& $runtimeState['runtime']['clock']; $clock['day'] = max(1, (int)($clock['day'] ?? $config['starting_day'])); if ($config['mode'] === 'days') { $clock['tick_in_day'] = max(0, (int)($clock['tick_in_day'] ?? 0)) + 1; if ($clock['tick_in_day'] >= $config['ticks_per_day']) { $clock['day']++; $clock['tick_in_day'] = 0; } return; } $clock['hour'] = max(0, (int)($clock['hour'] ?? $config['starting_hour'])); $clock['tick_in_hour'] = max(0, (int)($clock['tick_in_hour'] ?? 0)) + 1; if ($clock['tick_in_hour'] >= $config['ticks_per_hour']) { $clock['tick_in_hour'] = 0; $clock['hour']++; if ($clock['hour'] >= $config['hours_per_day']) { $clock['hour'] = 0; $clock['day']++; } } } function wayfarerRuntimeClockLabel(array $runtimeState, array $timeConfig): string { if ($timeConfig['mode'] === 'none') { return ''; } $clock = $runtimeState['runtime']['clock'] ?? []; $day = max(1, (int)($clock['day'] ?? $timeConfig['starting_day'])); if ($timeConfig['mode'] === 'days') { return 'Day ' . $day; } $hour = max(0, (int)($clock['hour'] ?? $timeConfig['starting_hour'])); if ($timeConfig['hours_per_day'] === 24) { $displayHour = $hour % 12; if ($displayHour === 0) { $displayHour = 12; } $suffix = $hour < 12 ? 'AM' : 'PM'; return 'Day ' . $day . ' · ' . $displayHour . ':00 ' . $suffix; } return 'Day ' . $day . ' · Hour ' . $hour; } /* * WAYFARER_BIDIRECTIONAL_TRAVEL_V71 * * Runtime direction is journey state, not Region authoring state. +1 follows * the Region's authored forward Tick Effect; -1 reverses it. Keeping the * authored Add/Subtract sign as the base preserves existing Regions while * allowing the same line to be traversed in either direction. */ function wayfarerRuntimeNavigationDirection(array $runtimeState): int { $direction = (int)( $runtimeState['navigation']['direction'] ?? 1 ); return $direction < 0 ? -1 : 1; } /* * WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 * * Returns canonical dedicated travel configuration, or null for Regions that * still use the legacy Position Tick Effect movement path. */ function wayfarerRuntimeDedicatedLinearTravel( array $regionConfig ): ?array { $travel = isset($regionConfig['travel']) && is_array($regionConfig['travel']) ? $regionConfig['travel'] : null; if ( $travel === null || empty($travel['enabled']) || !isset($travel['base_distance']) || !is_numeric($travel['base_distance']) || (float)$travel['base_distance'] <= 0 ) { return null; } return [ 'base_distance' => (float)$travel['base_distance'], 'allow_turnaround' => !empty( $travel['allow_turnaround'] ), ]; } /* WAYFARER_GAME_TRAVEL_MODIFIERS_MOVEMENT_V73 */ function wayfarerRuntimeApplyDedicatedLinearTravelMovement( array &$runtimeState, array $travel, float $modifierAdjustment = 0.0, array $matchedModifiers = [] ): array { if ( !isset($runtimeState['navigation']) || !is_array($runtimeState['navigation']) ) { $runtimeState['navigation'] = []; } $before = $runtimeState['navigation']['position'] ?? 0; if (!is_numeric($before)) { return [ 'scope' => 'navigation', 'type' => 'position', 'target_id' => 'position', 'applied' => false, 'error' => 'Navigation position must be numeric.', ]; } $baseDistance = max( 0.0, (float)( $travel['base_distance'] ?? 0 ) ); /* * Modifiers may slow travel to zero, but never reverse the player's * chosen direction. Direction is applied only after effective distance is * clamped to a non-negative value. */ $distance = max( 0.0, $baseDistance + $modifierAdjustment ); $direction = wayfarerRuntimeNavigationDirection( $runtimeState ); $after = max( 0.0, (float)$before + ($distance * $direction) ); $after = wayfarerNormalizeRuntimeNumber( $after ); $runtimeState['navigation']['position'] = $after; return [ 'scope' => 'navigation', 'type' => 'position', 'target_id' => 'position', 'operation' => 'travel', 'base_distance' => wayfarerNormalizeRuntimeNumber( $baseDistance ), 'modifier_adjustment' => wayfarerNormalizeRuntimeNumber( $modifierAdjustment ), 'matched_modifiers' => array_values( $matchedModifiers ), 'amount' => wayfarerNormalizeRuntimeNumber( $distance ), 'direction' => $direction, 'before' => wayfarerNormalizeRuntimeNumber( (float)$before ), 'after' => $after, 'applied' => true, 'error' => '', ]; } function wayfarerRuntimeLinearTravelDirection( array $regionConfig, array $runtimeState ): int { $runtimeDirection = wayfarerRuntimeNavigationDirection( $runtimeState ); /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ if ( wayfarerRuntimeDedicatedLinearTravel( $regionConfig ) !== null ) { return $runtimeDirection; } $effects = isset($regionConfig['tick_effects']) && is_array($regionConfig['tick_effects']) ? $regionConfig['tick_effects'] : []; foreach ($effects as $effect) { if (!is_array($effect)) { continue; } if ( (string)($effect['target_scope'] ?? '') !== 'navigation' || (string)($effect['target_type'] ?? '') !== 'position' || (string)($effect['target_id'] ?? '') !== 'position' ) { continue; } $branches = wayfarerRuntimeTickEffectBranches($effect); foreach ($branches as $branch) { $operation = strtolower(trim((string)($branch['operation'] ?? ''))); if ($operation === 'subtract') { return -1 * $runtimeDirection; } if ($operation === 'add') { return 1 * $runtimeDirection; } } } return $runtimeDirection; } function wayfarerRuntimeBuildTravelStatus( PDO $pdo, int $gameId, array &$runtimeState ): array { $gameConfig = wayfarerRuntimeLoadGameConfig($pdo, $gameId); $timeConfig = wayfarerRuntimeEnsureClock($pdo, $gameId, $runtimeState); $result = [ 'time_label' => wayfarerRuntimeClockLabel($runtimeState, $timeConfig), 'region_name' => '', 'distance_label' => 'units', 'next_location_id' => 0, 'next_location_name' => '', 'distance_to_next' => null, 'next_label' => '', /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ 'dedicated_travel' => false, 'base_distance_per_tick' => null, 'allow_turnaround' => false, 'can_turn_around' => false, 'direction' => 1, ]; $regionId = (int)($runtimeState['navigation']['region_id'] ?? 0); $position = $runtimeState['navigation']['position'] ?? null; if ($regionId <= 0 || !is_numeric($position)) { return $result; } $stmt = $pdo->prepare( 'SELECT id, name, navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1' ); $stmt->execute([$regionId, $gameId]); $region = $stmt->fetch(PDO::FETCH_ASSOC); if (!$region) { return $result; } $result['region_name'] = trim((string)($region['name'] ?? '')); if (strtolower(trim((string)($region['navigation_type'] ?? ''))) !== 'linear') { return $result; } $regionConfig = wayfarerRuntimeDecodeConfig($region['config'] ?? null); $distanceLabel = trim((string)($regionConfig['distance_label'] ?? 'units')); $result['distance_label'] = $distanceLabel !== '' ? $distanceLabel : 'units'; $direction = wayfarerRuntimeLinearTravelDirection($regionConfig, $runtimeState); $current = (float)$position; /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ $dedicatedTravel = wayfarerRuntimeDedicatedLinearTravel( $regionConfig ); $result['direction'] = $direction; if ($dedicatedTravel !== null) { $result['dedicated_travel'] = true; $result['base_distance_per_tick'] = wayfarerNormalizeRuntimeNumber( (float)$dedicatedTravel[ 'base_distance' ] ); $result['allow_turnaround'] = !empty( $dedicatedTravel[ 'allow_turnaround' ] ); /* * Do not offer a reversal that would immediately point out of the * legal lower boundary. If already traveling toward Start at zero, * turning around toward End remains valid. */ $result['can_turn_around'] = $result['allow_turnaround'] && !( $current <= 0 && $direction > 0 ); } $locationStmt = $pdo->prepare( "SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id" ); $locationStmt->execute([$regionId]); $best = null; $bestDistance = null; foreach ($locationStmt->fetchAll(PDO::FETCH_ASSOC) as $location) { $locationConfig = wayfarerRuntimeDecodeConfig($location['config'] ?? null); if (!isset($locationConfig['position']) || !is_numeric($locationConfig['position'])) { continue; } $locationPosition = (float)$locationConfig['position']; $ahead = $direction < 0 ? $locationPosition < $current : $locationPosition > $current; if (!$ahead) { continue; } $location['_runtime_config'] = $locationConfig; if (!wayfarerRuntimeLocationConditionsPass($pdo, $gameId, $location, $runtimeState)) { continue; } $distance = abs($locationPosition - $current); if ($best === null || $distance < $bestDistance) { $best = $location; $bestDistance = $distance; } } if ($best !== null) { $distance = wayfarerNormalizeRuntimeNumber((float)$bestDistance); $name = trim((string)($best['name'] ?? 'Location')) ?: 'Location'; $result['next_location_id'] = (int)($best['id'] ?? 0); $result['next_location_name'] = $name; $result['distance_to_next'] = $distance; $result['next_label'] = $name . ' — ' . $distance . ' ' . $result['distance_label']; } return $result; } function wayfarerRuntimeActiveTraitDefinitions( array $config ): array { $traits = $config['traits'] ?? []; if (!is_array($traits)) { return []; } $result = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( array_key_exists( 'active', $trait ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $trait['_runtime_id'] = $traitId; $result[] = $trait; } return $result; } /* * WAYFARER_TRAIT_RANDOM_INITIAL_RUNTIME_V93 * * Number Traits may initialize from either one fixed value or one random * Min–Max range. This function is called only when the owning runtime Trait * value does not already exist, so a random initial value is rolled once and * then persisted like every other Trait value. */ function wayfarerRuntimeInitialTraitValue( array $trait ) { $type = strtolower( trim( (string)( $trait['type'] ?? '' ) ) ); $initialMode = strtolower( trim( (string)( $trait['initial_mode'] ?? 'fixed' ) ) ); if ( $type === 'number' && $initialMode === 'random' ) { $minimum = $trait['initial_min'] ?? null; $maximum = $trait['initial_max'] ?? null; if ( is_numeric($minimum) && is_numeric($maximum) && (float)$minimum <= (float)$maximum ) { $rolled = wayfarerRuntimeRollEffectValue([ 'value_mode' => 'random', 'value_min' => $minimum, 'value_max' => $maximum, ]); if (!empty($rolled['valid'])) { $value = $rolled['value']; /* * Authored Trait bounds remain invariants even if malformed * legacy JSON contains a random range outside those bounds. */ if ( function_exists( 'wayfarerRuntimeClampValueToTraitDefinition' ) ) { $bounded = wayfarerRuntimeClampValueToTraitDefinition( $value, $trait ); if (!empty($bounded['valid'])) { return $bounded['value']; } } return $value; } } } if ( array_key_exists( 'initial', $trait ) ) { return $trait['initial']; } if ( $type === 'number' || $type === 'binary' || $type === 'boolean' ) { return 0; } return null; } function wayfarerRuntimeInitialTraitState( array $config ): array { $state = []; foreach ( wayfarerRuntimeActiveTraitDefinitions( $config ) as $trait ) { $traitId = (string)$trait['_runtime_id']; $state[$traitId] = wayfarerRuntimeInitialTraitValue( $trait ); } return $state; } /* |-------------------------------------------------------------------------- | Persisted state for every Region |-------------------------------------------------------------------------- | WAYFARER_ALL_REGION_STATE_V46 | | Game-wide Conditions may inspect a Region that the player has never visited. | Therefore every authored Region needs persistent Trait state, not only the | current Region. | | This helper is additive/recovery-safe: | - existing runtime values are never overwritten; | - missing Regions or newly-added Traits receive their authored initial; | - stale historical values are not destructively removed here. | */ function wayfarerRuntimeEnsureAllRegionTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['regions']) || !is_array($runtimeState['regions']) ) { $runtimeState['regions'] = []; } $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? ORDER BY id " ); $stmt->execute([ $gameId ]); $regions = $stmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($regions as $region) { $regionId = (int)( $region['id'] ?? 0 ); if ($regionId <= 0) { continue; } $regionKey = (string)$regionId; $config = wayfarerRuntimeDecodeConfig( $region['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset( $runtimeState['regions'][$regionKey] ) || !is_array( $runtimeState['regions'][$regionKey] ) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset( $runtimeState['regions'][$regionKey]['traits'] ) || !is_array( $runtimeState['regions'][$regionKey]['traits'] ) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['regions'][$regionKey]['traits'] ) ) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $initialValue; } } } /* |-------------------------------------------------------------------------- | Persisted state for every Location | WAYFARER_ALL_LOCATION_STATE_V56 |-------------------------------------------------------------------------- | | Locations can own Traits, so universal Conditions/Effects need persistent | per-playthrough Location Trait state just like Regions. | */ function wayfarerRuntimeEnsureAllLocationTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['locations']) || !is_array($runtimeState['locations']) ) { $runtimeState['locations'] = []; } $stmt = $pdo->prepare( " SELECT l.id, l.region_id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $location ) { $locationId = (int)($location['id'] ?? 0); if ($locationId <= 0) { continue; } $locationKey = (string)$locationId; $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset($runtimeState['locations'][$locationKey]) || !is_array($runtimeState['locations'][$locationKey]) ) { $runtimeState['locations'][$locationKey] = []; } /* * WAYFARER_LIVE_LOCATION_ANCHORS_V76 * * These are definition-derived spatial facts, so refresh them every * ensure pass. Moving a Location in Builder therefore changes the * anchor without rewriting authored Conditions. */ $runtimeState['locations'][$locationKey]['region_id'] = (int)( $location['region_id'] ?? 0 ); $runtimeState['locations'][$locationKey]['position'] = isset($config['position']) && is_numeric($config['position']) ? (float)$config['position'] : null; if ( !isset($runtimeState['locations'][$locationKey]['traits']) || !is_array($runtimeState['locations'][$locationKey]['traits']) ) { $runtimeState['locations'][$locationKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['locations'][$locationKey]['traits'] ) ) { continue; } $runtimeState['locations'][$locationKey]['traits'][$traitId] = $initialValue; } } } function wayfarerRuntimeLoadStartingRegion( PDO $pdo, int $gameId, ?array $gameConfig = null ): ?array { if ($gameId <= 0) { return null; } if ($gameConfig === null) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { return null; } $gameConfig = wayfarerRuntimeDecodeConfig( $rawConfig ); } $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT * FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $startingRegionId, $gameId, ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); return $region ?: null; } function wayfarerRuntimeInitializeGame( PDO $pdo, int $gameUserId, array $setupInput = [] ): array { if ($gameUserId <= 0) { throw new RuntimeException( 'A valid game-user ID is required to initialize the game.' ); } try { $pdo->beginTransaction(); /* * Lock the playthrough row so two initialize requests cannot create * competing starting states. */ $stmt = $pdo->prepare( " SELECT gu.id, gu.game_id, gu.user_id, gu.role, gu.state, g.config AS game_config, g.player_slots AS player_slots FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id WHERE gu.id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } /* * Initialization is intentionally one-way. To restart a development * game, clear wf_game_users.state to NULL first. */ if ( $row['state'] !== null && $row['state'] !== '' ) { throw new RuntimeException( 'This game-user already has runtime state.' ); } $gameId = (int)$row['game_id']; $gameConfig = wayfarerRuntimeDecodeConfig( $row['game_config'] ?? null ); $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { throw new RuntimeException( 'This Game does not have a Starting Region configured.' ); } $startingRegion = wayfarerRuntimeLoadStartingRegion( $pdo, $gameId, $gameConfig ); if (!$startingRegion) { throw new RuntimeException( 'The configured Starting Region does not exist for this Game.' ); } $regionConfig = wayfarerRuntimeDecodeConfig( $startingRegion['config'] ?? null ); $gameTraits = wayfarerRuntimeInitialTraitState( $gameConfig ); $regionTraits = wayfarerRuntimeInitialTraitState( $regionConfig ); $navigation = [ 'region_id' => (int)$startingRegion['id'], /* WAYFARER_BIDIRECTIONAL_TRAVEL_V71 */ 'direction' => 1, ]; /* * Linear travel currently begins at coordinate zero. * Other navigation modes will get their own authored start-state * rules when those modes are implemented. */ if ( strtolower( trim( (string)( $startingRegion['navigation_type'] ?? '' ) ) ) === 'linear' ) { $navigation['position'] = 0; } $regionKey = (string)$startingRegion['id']; $state = [ /* * WAYFARER_RUNTIME_SCHEMA_V55 * WAYFARER_BIDIRECTIONAL_TRAVEL_V71 * Version the persisted playthrough contract explicitly. */ 'schema_version' => 2, 'game' => [ 'traits' => $gameTraits, ], 'navigation' => $navigation, 'regions' => [ $regionKey => [ 'traits' => $regionTraits, ], ], 'runtime' => [ 'tick' => 0, ], ]; /* WAYFARER_INITIALIZE_GAME_CLOCK_V61 */ wayfarerRuntimeInitializeClockFromConfig( $gameConfig, $state ); /* * WAYFARER_ALL_REGION_STATE_V46 * * Global conditions can inspect Region state before that Region has * ever been visited. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $state ); /* WAYFARER_INITIALIZE_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_V49 * * Items and Characters receive canonical per-playthrough state before * any starting setup effects are applied. */ wayfarerRuntimeEnsureEntityState( $pdo, $gameId, $state ); /* * WAYFARER_APPLY_STARTING_CHOICES_V36 * * Validate all player-entered setup data before persisting anything. * Then apply the selected options' starting effects to the canonical * initial runtime state. */ $preparedSetup = wayfarerRuntimePrepareStartingSetup( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $setupInput ); /* * WAYFARER_PLAYER_TRAITS_RUNTIME_V52 * * Persist the validated player-character names before creating the * per-player Trait state. Each slot receives independent values. */ $state['setup'] = [ 'player_names' => $preparedSetup['player_names'], 'choices' => $preparedSetup['choices'], ]; wayfarerRuntimeEnsureGameTraitState( $gameConfig, $state ); wayfarerRuntimeEnsurePlayerState( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $state ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $startingEffectReports = []; foreach ( $preparedSetup['effects'] as $startingEffect ) { /* WAYFARER_STARTING_CHOICE_RANDOM_RUNTIME_V58 */ $resolvedStartingValue = wayfarerRuntimeRollEffectValue( $startingEffect ); if (!$resolvedStartingValue['valid']) { throw new RuntimeException( 'Unable to resolve a Starting Choice effect value: ' . $resolvedStartingValue['error'] ); } $concreteStartingEffect = $startingEffect; $concreteStartingEffect['value'] = $resolvedStartingValue['value']; $report = wayfarerApplyRuntimeEffect( $state, $concreteStartingEffect, (int)$startingRegion['id'], $traitDefinitions ); if ( !empty($report['error']) ) { throw new RuntimeException( 'Unable to apply a Starting Choice effect: ' . $report['error'] ); } $report['value_mode'] = $resolvedStartingValue['mode']; $report['rolled_value'] = $resolvedStartingValue['value']; if ($resolvedStartingValue['mode'] === 'random') { $report['random_min'] = $resolvedStartingValue['min']; $report['random_max'] = $resolvedStartingValue['max']; } $startingEffectReports[] = $report; } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * * Starting-effect reports are intentionally not persisted. The effects * themselves have already mutated canonical state. */ /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 * * A game that begins exactly on an active Location should present that * Location before ordinary Action Mode. */ $startingLocation = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $state ); if ($startingLocation) { /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Starting on a Location is a real Location trigger. Apply its * one-time Effects before opening Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, $gameId, $startingLocation, $state ); $locationStoryletOpened = wayfarerRuntimePromotePendingLocationStorylet( $pdo, $gameId, $startingLocation, $state, 'initial' ); if (!$locationStoryletOpened) { $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$startingLocation['id'], 'phase' => 'presentation', 'trigger' => 'initial', 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } } /* WAYFARER_GAME_ENDINGS_INITIAL_V45 */ wayfarerRuntimeEvaluateGameEndings( $pdo, $gameId, $state ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => $gameId, 'starting_region_id' => (int)$startingRegion['id'], 'state' => $state, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Resolve a game-user record for the tick prototype. * * Preferred: * explicit wf_game_users.id * * Temporary development fallback: * first wf_game_users record. * * The fallback exists only because Wayfarer has not yet wired the play * page to authentication / game selection. Once that exists, remove the * fallback and always pass the active game_user_id explicitly. */ function wayfarerRuntimeResolveGameUserId( PDO $pdo, ?int $requestedId = null ): int { if ($requestedId !== null && $requestedId > 0) { $stmt = $pdo->prepare( " SELECT id FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $requestedId ]); $id = $stmt->fetchColumn(); if ($id !== false) { return (int)$id; } throw new RuntimeException( 'The requested game-user record does not exist.' ); } $id = $pdo ->query( " SELECT id FROM wf_game_users ORDER BY id LIMIT 1 " ) ->fetchColumn(); if ($id === false) { throw new RuntimeException( 'No wf_game_users record exists.' ); } return (int)$id; } /** * Load one game-user runtime state. */ function wayfarerRuntimeLoad( PDO $pdo, int $gameUserId ): array { $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'state' => $state, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /** * Advance exactly one tick. * * This is intentionally the entire v14 game engine: * * state.runtime.tick += 1 * * The transaction and row lock are already in place so later work can add * movement/effects without changing the request architecture. */ /* |-------------------------------------------------------------------------- | Linear Location crossing | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | A tick may move farther than the distance to the next authored Location. | This resolver finds the FIRST active Location crossed in the direction of | travel. The movement is then clamped to that exact authored coordinate. | | Examples: | 45 -> 55, Location 50 => stop at 50 | 55 -> 45, Location 50 => stop at 50 | | The starting coordinate is intentionally exclusive so leaving a Location | does not immediately trigger that same Location again. | */ /* |-------------------------------------------------------------------------- | Location Conditions | WAYFARER_LOCATION_CONDITIONS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeLocationConditionsPass( PDO $pdo, int $gameId, array $location, array &$runtimeState ): bool { $config = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $conditions = $config['conditions'] ?? null; $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; return wayfarerConditionsPass( $conditions, $resolver ); } /* |-------------------------------------------------------------------------- | Game Endings | WAYFARER_GAME_ENDINGS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeEvaluateGameEndings( PDO $pdo, int $gameId, array &$runtimeState ): ?array { /* * WAYFARER_ALL_REGION_STATE_ENDINGS_V46 */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $runtimeState ); /* WAYFARER_RECOVER_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $runtimeState ); if ( isset( $runtimeState['runtime']['ending'] ) && is_array( $runtimeState['runtime']['ending'] ) ) { return $runtimeState['runtime']['ending']; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); $config = wayfarerRuntimeDecodeConfig( $raw === false ? null : $raw ); $endings = $config['endings'] ?? []; if (!is_array($endings)) { return null; } $ordered = []; foreach ( array_values($endings) as $index => $ending ) { if (!is_array($ending)) { continue; } $ending['_runtime_order'] = $index; $ordered[] = $ending; } usort( $ordered, function ( array $a, array $b ): int { $priorityCompare = (int)( $b['priority'] ?? 0 ) <=> (int)( $a['priority'] ?? 0 ); if ($priorityCompare !== 0) { return $priorityCompare; } return (int)( $a['_runtime_order'] ?? 0 ) <=> (int)( $b['_runtime_order'] ?? 0 ); } ); $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; foreach ($ordered as $ending) { if ( !wayfarerConditionsPass( $ending['conditions'] ?? null, $resolver ) ) { continue; } $terminal = [ 'id' => (string)( $ending['id'] ?? '' ), 'title' => (string)( $ending['title'] ?? 'The End' ), 'text' => (string)( $ending['text'] ?? '' ), 'image_media_id' => (int)( $ending['image_media_id'] ?? 0 ), 'priority' => (int)( $ending['priority'] ?? 0 ), 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), ]; if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['ending'] = $terminal; unset( $runtimeState['runtime']['presentation'] ); return $terminal; } return null; } function wayfarerRuntimeFindCrossedLocation( PDO $pdo, int $gameId, int $regionId, $fromPosition, $toPosition, array &$runtimeState ): ?array { /* * WAYFARER_CROSSED_LOCATION_STATE_FIX_V46_1 * * Location Conditions are evaluated against the live runtime state. */ if ( $gameId <= 0 || $regionId <= 0 || !is_numeric($fromPosition) || !is_numeric($toPosition) ) { return null; } $from = (float)$fromPosition; $to = (float)$toPosition; if ($from === $to) { return null; } /* * Crossing interception currently applies only to Linear Regions. */ $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); $forward = $to > $from; $candidate = null; $candidatePosition = null; foreach ($locations as $location) { $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $locationPosition = (float)$config['position']; /* * The starting point is exclusive; the destination is inclusive. */ $crossed = $forward ? ( $locationPosition > $from && $locationPosition <= $to ) : ( $locationPosition < $from && $locationPosition >= $to ); if (!$crossed) { continue; } $location['_runtime_config'] = $config; if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } if ($candidate === null) { $candidate = $location; $candidatePosition = $locationPosition; } else { $nearer = $forward ? ( $locationPosition < $candidatePosition ) : ( $locationPosition > $candidatePosition ); $samePositionLowerId = $locationPosition === $candidatePosition && (int)$location['id'] < (int)$candidate['id']; if ( $nearer || $samePositionLowerId ) { $candidate = $location; $candidatePosition = $locationPosition; } } } if ($candidate === null) { return null; } $candidate['_runtime_position'] = wayfarerNormalizeRuntimeNumber( (float)$candidatePosition ); $candidate['_runtime_config'] = wayfarerRuntimeDecodeConfig( $candidate['config'] ?? null ); return $candidate; } /* |-------------------------------------------------------------------------- | Player spatial context |-------------------------------------------------------------------------- | WAYFARER_PLAYER_CONTEXT_V46_1 | | This is the canonical player-facing spatial label for Action Mode: | | exact eligible Location -> Location name + Region parent context | otherwise -> Region name | | It is returned by runtime APIs so the browser does not keep displaying the | Location name from the page's original server render after movement. | */ function wayfarerRuntimePlayerContext( PDO $pdo, int $gameId, array $runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionName = ''; if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $rawRegionName = $stmt->fetchColumn(); if ($rawRegionName !== false) { $regionName = trim( (string)$rawRegionName ); } } $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); $locationId = 0; $locationName = ''; if ($location) { $locationId = (int)( $location['id'] ?? 0 ); $locationName = trim( (string)( $location['name'] ?? '' ) ); } $contextName = $locationName !== '' ? $locationName : ( $regionName !== '' ? $regionName : 'Actions' ); return [ 'region_id' => $regionId, 'region_name' => $regionName, 'location_id' => $locationId, 'location_name' => $locationName, 'context_name' => $contextName, 'parent_context' => $locationName !== '' ? $regionName : '', ]; } /* |-------------------------------------------------------------------------- | Presentation dismissal |-------------------------------------------------------------------------- | | Presentation is persisted runtime state. This function is intentionally | generic even though v40 renders only Location presentations; Storylets can | use the same mechanism later. | */ function wayfarerRuntimeDismissPresentation( PDO $pdo, int $gameUserId, string $expectedType = '', int $expectedId = 0 ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); $presentation = $state['runtime']['presentation'] ?? null; if ( !is_array($presentation) || empty($presentation['type']) || empty($presentation['id']) ) { throw new RuntimeException( 'There is no active presentation to continue.' ); } if ( $expectedType !== '' && (string)$presentation['type'] !== $expectedType ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } if ( $expectedId > 0 && (int)$presentation['id'] !== $expectedId ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } /* WAYFARER_STORYLET_BRANCH_DISMISS_GUARD_V59 */ if ( (string)($presentation['type'] ?? '') === 'storylet' ) { $eligibleBranches = wayfarerRuntimeEligibleStoryletBranches( $pdo, (int)$row['game_id'], (int)($presentation['id'] ?? 0), $state ); if ($eligibleBranches) { throw new RuntimeException( 'Choose an available Storylet Branch before continuing.' ); } } $state['runtime']['last_presentation'] = $presentation; unset( $state['runtime']['presentation'] ); /* WAYFARER_STATUS_STORYLET_QUEUE_V88 */ wayfarerRuntimePromotePendingStatusStorylet( $pdo, (int)$row['game_id'], $state ); wayfarerRuntimeEvaluateGameEndings( $pdo, (int)$row['game_id'], $state ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'dismissed' => $presentation, /* WAYFARER_STORYLET_ACTIONS_AFTER_PRESENTATION_V59 */ 'actions' => isset($state['runtime']['presentation']) && is_array($state['runtime']['presentation']) ? [] : wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$row['game_id'], $state ), /* WAYFARER_STATUS_STORYLET_QUEUE_V88 */ 'presentation' => isset($state['runtime']['presentation']) && is_array($state['runtime']['presentation']) ? $state['runtime']['presentation'] : null, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Advance exactly one tick. * * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ function wayfarerRuntimeAdvanceTick( PDO $pdo, int $gameUserId, ?int $requestedTravelDirection = null ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } /* WAYFARER_BIDIRECTIONAL_TRAVEL_V71 */ if ( !isset($state['navigation']) || !is_array($state['navigation']) ) { $state['navigation'] = []; } if ( $requestedTravelDirection !== null && !in_array( $requestedTravelDirection, [-1, 1], true ) ) { throw new RuntimeException( 'Travel direction must be -1 or 1.' ); } $state['navigation']['direction'] = $requestedTravelDirection !== null ? $requestedTravelDirection : wayfarerRuntimeNavigationDirection( $state ); $state['schema_version'] = max( 2, (int)( $state['schema_version'] ?? 1 ) ); /* * WAYFARER_GAME_ENDINGS_TERMINAL_V45 * * Endings are terminal runtime state. */ if ( isset( $state['runtime']['ending'] ) && is_array( $state['runtime']['ending'] ) ) { throw new RuntimeException( 'This game has ended.' ); } /* * Narrative presentation is a true runtime pause, not merely a hidden * browser control. Even Author Mode cannot advance ticks through it. */ if ( isset( $state['runtime']['presentation'] ) && is_array( $state['runtime']['presentation'] ) ) { throw new RuntimeException( 'Continue the current presentation before advancing time.' ); } /* * WAYFARER_ALL_REGION_STATE_TICK_V46 * * Recovery path for playthroughs initialized before v46, and for * Regions/Traits authored after a playthrough began. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_LOCATION_STATE_TICK_RECOVERY_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, (int)$row['game_id'], $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_TICK_V49 * * Recovery path for playthroughs created before V49 and for Entities * or Traits authored after play began. */ wayfarerRuntimeEnsureEntityState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_PLAYER_TRAITS_TICK_RECOVERY_V52 */ wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$row['game_id'], $state ); $regionIdBeforeTick = (int)( $state['navigation']['region_id'] ?? 0 ); $positionBeforeTick = $state['navigation']['position'] ?? null; $currentTick = (int)( $state['runtime']['tick'] ?? 0 ); $state['runtime']['tick'] = $currentTick + 1; /* WAYFARER_ADVANCE_GAME_CLOCK_V61 */ wayfarerRuntimeAdvanceClock( $pdo, (int)$row['game_id'], $state ); /* * Global Game Tick Effects run first. */ wayfarerApplyGameTickEffects( $pdo, (int)$row['game_id'], $state ); /* * Current Region Tick Effects then perform Region-level movement and * state changes. */ wayfarerApplyCurrentRegionTickEffects( $pdo, $state ); /* * Resolve whether the completed movement would have crossed an active * Location. If so, stop at the nearest crossed Location rather than * accepting the overshot position. */ $regionIdAfterMovement = (int)( $state['navigation']['region_id'] ?? 0 ); $positionAfterMovement = $state['navigation']['position'] ?? null; $crossedLocation = null; /* * We only interpret movement as a continuous path while the player * remains in the same Region. Region transitions will later get their * own explicit navigation semantics. */ if ( $regionIdBeforeTick > 0 && $regionIdBeforeTick === $regionIdAfterMovement ) { $crossedLocation = wayfarerRuntimeFindCrossedLocation( $pdo, (int)$row['game_id'], $regionIdAfterMovement, $positionBeforeTick, $positionAfterMovement, $state ); } $arrivalPosition = null; if ($crossedLocation) { $arrivalPosition = $crossedLocation[ '_runtime_position' ]; $state['navigation']['position'] = $arrivalPosition; /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Event Effects execute once at the trigger boundary. They run * before the Location's Tick Effects and before Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, (int)$row['game_id'], $crossedLocation, $state ); } /* * WAYFARER_LOCATION_EFFECTS_ONLY_RUNTIME_V43 * * Locations no longer own Tick Effects. Game and Region Tick Effects * have already advanced the simulation; a crossed Location contributes * its one-time Effects and then interrupts with Presentation Mode. */ if ($crossedLocation) { $state['navigation']['position'] = $arrivalPosition; $locationStoryletOpened = wayfarerRuntimePromotePendingLocationStorylet( $pdo, (int)$row['game_id'], $crossedLocation, $state, 'arrival' ); if (!$locationStoryletOpened) { $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$crossedLocation['id'], 'phase' => 'presentation', 'trigger' => 'arrival', 'tick' => (int)$state['runtime']['tick'], ]; } $state['runtime']['last_location_arrival'] = [ 'location_id' => (int)$crossedLocation['id'], 'region_id' => $regionIdAfterMovement, 'position' => $arrivalPosition, 'tick' => (int)$state['runtime']['tick'], 'storylet_transition' => $locationStoryletOpened, ]; } /* WAYFARER_STATUS_LIFECYCLE_TICK_V88 */ wayfarerRuntimeProcessStatusProgression( $pdo, (int)$row['game_id'], $state ); /* * A Location presentation keeps priority for the completed travel * tick. Status Storylets queue behind it and are promoted as soon as * there is no active presentation. */ wayfarerRuntimePromotePendingStatusStorylet( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_GAME_ENDINGS_AFTER_TICK_V45 */ $ending = wayfarerRuntimeEvaluateGameEndings( $pdo, (int)$row['game_id'], $state ); $state['runtime']['last_tick_at'] = gmdate('c'); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId ]); $pdo->commit(); $presentation = $state['runtime']['presentation'] ?? null; $pausedForPresentation = is_array($presentation) && !empty($presentation['type']) && !empty($presentation['id']); $ending = $state['runtime']['ending'] ?? null; $ended = is_array($ending) && !empty($ending['id']); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'tick' => (int)$state['runtime']['tick'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'pause' => $pausedForPresentation || $ended, 'pause_reason' => $ended ? 'Game ended.' : ( $pausedForPresentation ? ( (string)($presentation['type'] ?? '') === 'storylet' ? 'Storylet opened.' : 'Location reached.' ) : '' ), 'presentation' => $pausedForPresentation ? $presentation : null, 'ending' => $ended ? $ending : null, /* WAYFARER_STORYLET_ACTIONS_TICK_API_V59 */ 'actions' => !$pausedForPresentation && !$ended ? wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$row['game_id'], $state ) : [], /* WAYFARER_TRAVEL_TIME_HUD_V61 */ 'travel' => wayfarerRuntimeBuildTravelStatus( $pdo, (int)$row['game_id'], $state ), ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: _update_backups/v103-20260930-212529/play/includes/storylet-bindings.php ================================================================================ $key, 'label' => $label !== '' ? $label : $key, 'selector' => $selector, ]; } return $bindings; } function wayfarerRuntimeStoryletBindingRequiredTargets( array $storylet, string $bindingKey ): array { $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); $effects = isset($config['effects']) && is_array($config['effects']) ? $config['effects'] : []; $scope = 'binding:' . $bindingKey; $required = []; foreach ($effects as $effect) { if (!is_array($effect)) { continue; } if ( strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ) !== $scope ) { continue; } $targetType = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); if ( $targetType === '' || $targetId === '' ) { continue; } $required[ $targetType . ':' . $targetId ] = [ 'target_type' => $targetType, 'target_id' => $targetId, ]; } return array_values($required); } function wayfarerRuntimeStoryletBindingFindNamedTrait( array $config, string $normalizedName ): ?array { $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; $match = null; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) || ( array_key_exists( 'active', $trait ) && !$trait['active'] ) ) { continue; } $name = wayfarerRuntimeStoryletBindingNormalizeTraitName( (string)( $trait['name'] ?? '' ) ); if ($name !== $normalizedName) { continue; } /* * Ambiguous duplicate names on one Entity are not safe for a generic * binding target. */ if ($match !== null) { return null; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { return null; } $match = $trait; } return $match; } function wayfarerRuntimeStoryletBindingEntitySupportsTargets( array $entity, array $requiredTargets ): array { $config = wayfarerEntityDecodeConfig( $entity['config'] ?? null ); $targetMap = []; foreach ($requiredTargets as $required) { if (!is_array($required)) { continue; } $targetType = strtolower( trim( (string)( $required['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $required['target_id'] ?? '' ) ); if ( $targetType === 'status' && preg_match('/^[1-9][0-9]*$/', $targetId) === 1 ) { $targetMap[$targetId] = $targetId; continue; } if ( $targetType !== 'trait' || $targetId === '' ) { return [ 'supported' => false, 'target_map' => [], ]; } if ( $targetId === 'sys_item_quantity' || $targetId === 'sys_character_party_member' ) { $targetMap[$targetId] = $targetId; continue; } $namedTrait = wayfarerRuntimeStoryletBindingDecodeNamedTraitId( $targetId ); if ($namedTrait === null) { return [ 'supported' => false, 'target_map' => [], ]; } $trait = wayfarerRuntimeStoryletBindingFindNamedTrait( $config, $namedTrait ); if (!is_array($trait)) { return [ 'supported' => false, 'target_map' => [], ]; } $actualTraitId = trim( (string)( $trait['id'] ?? '' ) ); if ($actualTraitId === '') { return [ 'supported' => false, 'target_map' => [], ]; } $targetMap[$targetId] = $actualTraitId; } return [ 'supported' => true, 'target_map' => $targetMap, ]; } /* WAYFARER_PARTY_BINDINGS_V85 */ function wayfarerRuntimeStoryletBindingFindPlayerTrait( array $gameConfig, string $normalizedName ): ?array { $traits = isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : []; $match = null; foreach ($traits as $trait) { if ( !is_array($trait) || ( array_key_exists('active', $trait) && !$trait['active'] ) ) { continue; } $name = wayfarerRuntimeStoryletBindingNormalizeTraitName( (string)( $trait['name'] ?? '' ) ); if ($name !== $normalizedName) { continue; } if ($match !== null) { return null; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { return null; } $match = $trait; } return $match; } function wayfarerRuntimeStoryletBindingPlayerSupportsTargets( array $gameConfig, array $playerState, array $requiredTargets ): array { $targetMap = []; $runtimeTraits = isset($playerState['traits']) && is_array($playerState['traits']) ? $playerState['traits'] : []; foreach ($requiredTargets as $required) { if (!is_array($required)) { continue; } $targetType = strtolower( trim( (string)( $required['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $required['target_id'] ?? '' ) ); if ( $targetType === 'status' && preg_match('/^[1-9][0-9]*$/', $targetId) === 1 ) { $targetMap[$targetId] = $targetId; continue; } if ( $targetType !== 'trait' || $targetId === '' || $targetId === 'sys_item_quantity' || $targetId === 'sys_character_party_member' ) { return [ 'supported' => false, 'target_map' => [], ]; } $namedTrait = wayfarerRuntimeStoryletBindingDecodeNamedTraitId( $targetId ); if ($namedTrait === null) { return [ 'supported' => false, 'target_map' => [], ]; } $trait = wayfarerRuntimeStoryletBindingFindPlayerTrait( $gameConfig, $namedTrait ); if (!is_array($trait)) { return [ 'supported' => false, 'target_map' => [], ]; } $actualTraitId = trim( (string)( $trait['id'] ?? '' ) ); if ( $actualTraitId === '' || !array_key_exists( $actualTraitId, $runtimeTraits ) ) { return [ 'supported' => false, 'target_map' => [], ]; } $targetMap[$targetId] = $actualTraitId; } return [ 'supported' => true, 'target_map' => $targetMap, ]; } /* WAYFARER_STATUS_SUBJECT_BINDING_V88 */ /* WAYFARER_STATUS_OWNER_BINDING_V92 */ function wayfarerRuntimeStoryletPresetCharacterBinding( PDO $pdo, int $gameId, array $storylet, array $runtimeState, string $runtimeScope, string $bindingKey = 'subject', string $label = 'Subject' ): array { $runtimeScope = strtolower(trim($runtimeScope)); $bindingKey = wayfarerRuntimeStoryletBindingNormalizeKey($bindingKey); if ( $bindingKey === '' || preg_match('/^[a-z][a-z0-9_]{0,31}$/', $bindingKey) !== 1 ) { return [ 'binding' => null, 'error' => 'Triggered Storylet subject binding key is invalid.', ]; } $requiredTargets = wayfarerRuntimeStoryletBindingRequiredTargets( $storylet, $bindingKey ); if ( preg_match('/^player:([0-9]+)$/', $runtimeScope, $match) === 1 ) { $slot = (int)$match[1]; $playerState = $runtimeState['players'][(string)$slot] ?? null; if (!is_array($playerState)) { return [ 'binding' => null, 'error' => 'Triggered Storylet Player subject was not found.', ]; } $gameConfig = function_exists('wayfarerRuntimeLoadGameConfig') ? wayfarerRuntimeLoadGameConfig($pdo, $gameId) : []; $support = wayfarerRuntimeStoryletBindingPlayerSupportsTargets( $gameConfig, $playerState, $requiredTargets ); if (empty($support['supported'])) { return [ 'binding' => null, 'error' => 'Triggered Storylet subject does not expose every Trait/Status required by the subject Binding Effects.', ]; } $name = trim((string)($playerState['name'] ?? '')); if ($name === '') { $name = 'Player ' . ($slot + 1); } return [ 'binding' => [ 'key' => $bindingKey, 'label' => $label, 'selector' => 'status_subject', 'runtime_scope' => 'player:' . $slot, 'subject_kind' => 'player', 'player_slot' => $slot, 'entity_id' => 0, 'entity_type' => 'player', 'name' => $name, 'target_map' => $support['target_map'] ?? [], ], 'error' => '', ]; } if ( preg_match('/^entity:([1-9][0-9]*)$/', $runtimeScope, $match) === 1 ) { $entityId = (int)$match[1]; $runtimeEntity = $runtimeState['entities'][(string)$entityId] ?? null; if (!is_array($runtimeEntity)) { return [ 'binding' => null, 'error' => 'Triggered Storylet Status Owner was not found.', ]; } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $entityId, $gameId, ]); $entity = $stmt->fetch(PDO::FETCH_ASSOC); if (!$entity) { return [ 'binding' => null, 'error' => 'Triggered Storylet Status Owner definition was not found.', ]; } $support = wayfarerRuntimeStoryletBindingEntitySupportsTargets( $entity, $requiredTargets ); if (empty($support['supported'])) { return [ 'binding' => null, 'error' => 'Triggered Storylet subject does not expose every Trait/Status required by the subject Binding Effects.', ]; } return [ 'binding' => [ 'key' => $bindingKey, 'label' => $label, 'selector' => 'status_subject', 'runtime_scope' => 'entity:' . $entityId, 'subject_kind' => 'entity', 'player_slot' => null, 'entity_id' => $entityId, 'entity_type' => strtolower(trim((string)($entity['entity_type'] ?? 'character'))), 'name' => trim((string)($entity['name'] ?? ('Character ' . $entityId))), 'target_map' => $support['target_map'] ?? [], ], 'error' => '', ]; } return [ 'binding' => null, 'error' => 'Triggered Storylet subject scope is invalid.', ]; } /* * Generic V92 name. The V88 implementation already resolves both Player scopes * and Entity scopes; V92 formally permits Item Entities as Status Owners too. */ function wayfarerRuntimeStoryletPresetStatusOwnerBinding( PDO $pdo, int $gameId, array $storylet, array $runtimeState, string $runtimeScope, string $bindingKey = 'subject', string $label = 'Subject' ): array { return wayfarerRuntimeStoryletPresetCharacterBinding( $pdo, $gameId, $storylet, $runtimeState, $runtimeScope, $bindingKey, $label ); } function wayfarerRuntimeStoryletBindingCandidates( PDO $pdo, int $gameId, array $storylet, array $binding, array $runtimeState ): array { $selector = strtolower( trim( (string)( $binding['selector'] ?? '' ) ) ); $bindingKey = wayfarerRuntimeStoryletBindingNormalizeKey( (string)( $binding['key'] ?? '' ) ); $requiredTargets = wayfarerRuntimeStoryletBindingRequiredTargets( $storylet, $bindingKey ); $candidates = []; /* * Named setup Player Characters are party members too. They do not use the * Entity Party Membership Trait; their presence in runtime.players is the * canonical membership signal for the playthrough. */ if ($selector === 'random_party_character') { $gameConfig = function_exists('wayfarerRuntimeLoadGameConfig') ? wayfarerRuntimeLoadGameConfig( $pdo, $gameId ) : []; $players = isset($runtimeState['players']) && is_array($runtimeState['players']) ? $runtimeState['players'] : []; foreach ($players as $playerKey => $playerState) { if (!is_array($playerState)) { continue; } $name = trim( (string)( $playerState['name'] ?? '' ) ); if ($name === '') { continue; } $support = wayfarerRuntimeStoryletBindingPlayerSupportsTargets( $gameConfig, $playerState, $requiredTargets ); if (empty($support['supported'])) { continue; } $slot = (int)( $playerState['slot'] ?? $playerKey ); $candidates[] = [ 'runtime_scope' => 'player:' . $slot, 'subject_kind' => 'player', 'player_slot' => $slot, 'entity_id' => 0, 'entity_type' => 'player', 'name' => $name, 'target_map' => $support['target_map'], ]; } } foreach ( wayfarerRuntimeLoadActiveEntities( $pdo, $gameId ) as $entity ) { if (!is_array($entity)) { continue; } $entityId = (int)( $entity['id'] ?? 0 ); if ($entityId <= 0) { continue; } $entityType = strtolower( trim( (string)( $entity['entity_type'] ?? '' ) ) ); $entityState = $runtimeState['entities'][ (string)$entityId ] ?? null; if (!is_array($entityState)) { continue; } $traits = isset($entityState['traits']) && is_array($entityState['traits']) ? $entityState['traits'] : []; if ( $selector === 'random_party_character' ) { if ( $entityType !== 'character' || empty( $traits[ 'sys_character_party_member' ] ) ) { continue; } } elseif ( $selector === 'random_possessed_item' ) { $quantity = $traits[ 'sys_item_quantity' ] ?? 0; if ( $entityType !== 'item' || !is_numeric($quantity) || (float)$quantity <= 0 ) { continue; } } else { continue; } $support = wayfarerRuntimeStoryletBindingEntitySupportsTargets( $entity, $requiredTargets ); if (empty($support['supported'])) { continue; } $candidates[] = [ 'runtime_scope' => 'entity:' . $entityId, 'subject_kind' => 'entity', 'player_slot' => null, 'entity_id' => $entityId, 'entity_type' => $entityType, 'name' => trim( (string)( $entity['name'] ?? ('Entity ' . $entityId) ) ), 'target_map' => $support['target_map'], ]; } return $candidates; } function wayfarerRuntimeStoryletBindingsAvailable( PDO $pdo, int $gameId, array $storylet, array $runtimeState ): bool { $definitions = wayfarerRuntimeStoryletBindingDefinitions( $storylet ); foreach ($definitions as $binding) { if ( wayfarerRuntimeStoryletBindingCandidates( $pdo, $gameId, $storylet, $binding, $runtimeState ) === [] ) { return false; } } return true; } function wayfarerRuntimeResolveStoryletBindings( PDO $pdo, int $gameId, array $storylet, array $runtimeState, array $presetBindings = [] ): array { $definitions = wayfarerRuntimeStoryletBindingDefinitions( $storylet ); $resolved = []; foreach ( $definitions as $key => $binding ) { /* WAYFARER_PERSISTENT_STORYLET_BINDINGS_V88 */ if ( isset($presetBindings[$key]) && is_array($presetBindings[$key]) ) { $resolved[$key] = $presetBindings[$key]; continue; } $candidates = wayfarerRuntimeStoryletBindingCandidates( $pdo, $gameId, $storylet, $binding, $runtimeState ); if ($candidates === []) { return [ 'bindings' => [], 'error' => 'Storylet Binding "' . ( $binding['label'] ?? $key ) . '" has no eligible runtime target.', ]; } $selected = $candidates[ random_int( 0, count($candidates) - 1 ) ]; $resolved[$key] = [ 'key' => $key, 'label' => (string)( $binding['label'] ?? $key ), 'selector' => (string)( $binding['selector'] ?? '' ), 'runtime_scope' => (string)( $selected['runtime_scope'] ?? '' ), 'subject_kind' => (string)( $selected['subject_kind'] ?? 'entity' ), 'player_slot' => isset($selected['player_slot']) ? (int)$selected['player_slot'] : null, 'entity_id' => (int)( $selected['entity_id'] ?? 0 ), 'entity_type' => (string)( $selected['entity_type'] ?? '' ), 'name' => (string)$selected['name'], 'target_map' => is_array( $selected['target_map'] ?? null ) ? $selected['target_map'] : [], ]; } /* * Preset bindings that are not explicitly defined on this Storylet are * still carried for narrative interpolation. This is how a Status Outcome * can supply the reserved {{subject}} placeholder without forcing every * presentation-only Storylet to author a duplicate Binding definition. */ foreach ($presetBindings as $key => $binding) { if ( !isset($resolved[$key]) && is_array($binding) ) { $resolved[$key] = $binding; } } return [ 'bindings' => $resolved, 'error' => '', ]; } function wayfarerRuntimeMaterializeStoryletBindingEffect( array $effect, array $bindings ): array { $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); if ( preg_match( '/^binding:([a-z][a-z0-9_]{0,31})$/', $scope, $match ) !== 1 ) { return [ 'effect' => $effect, 'error' => '', ]; } $key = $match[1]; $binding = $bindings[$key] ?? null; if (!is_array($binding)) { return [ 'effect' => [], 'error' => 'Resolved Storylet Binding "' . $key . '" is missing.', ]; } $runtimeScope = strtolower( trim( (string)( $binding['runtime_scope'] ?? '' ) ) ); /* * Compatibility for V79-V84 saved runtime binding records, which stored * only entity_id. */ if ($runtimeScope === '') { $entityId = (int)( $binding['entity_id'] ?? 0 ); if ($entityId > 0) { $runtimeScope = 'entity:' . $entityId; } } if ( preg_match( '/^(?:entity:[1-9][0-9]*|player:[0-9]+)$/', $runtimeScope ) !== 1 ) { return [ 'effect' => [], 'error' => 'Resolved Storylet Binding "' . $key . '" does not point to a valid Party Member or Entity.', ]; } $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); $targetType = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); /* WAYFARER_STATUS_BINDING_RUNTIME_V87 */ if ( $targetType === 'status' && preg_match('/^[1-9][0-9]*$/', $targetId) === 1 ) { $materialized = $effect; $materialized['target_scope'] = $runtimeScope; $materialized['target_id'] = $targetId; return [ 'effect' => $materialized, 'error' => '', ]; } $targetMap = is_array( $binding['target_map'] ?? null ) ? $binding['target_map'] : []; $entityScope = strpos( $runtimeScope, 'entity:' ) === 0; $actualTargetId = $targetMap[$targetId] ?? ( $entityScope && ( $targetId === 'sys_item_quantity' || $targetId === 'sys_character_party_member' ) ? $targetId : '' ); if ($actualTargetId === '') { return [ 'effect' => [], 'error' => 'The selected subject for Binding "' . $key . '" does not expose the required Trait.', ]; } $materialized = $effect; $materialized['target_scope'] = $runtimeScope; $materialized['target_id'] = $actualTargetId; if ( $entityScope && $actualTargetId === 'sys_item_quantity' ) { $materialized['target_system_key'] = 'quantity'; } elseif ( $entityScope && $actualTargetId === 'sys_character_party_member' ) { $materialized['target_system_key'] = 'party_member'; } else { unset( $materialized['target_system_key'] ); } return [ 'effect' => $materialized, 'error' => '', ]; } function wayfarerRuntimeInterpolateStoryletBindings( string $text, array $bindings ): string { if ( $text === '' || $bindings === [] ) { return $text; } $replace = function ( array $match ) use ( $bindings ): string { $key = wayfarerRuntimeStoryletBindingNormalizeKey( (string)( $match[1] ?? '' ) ); $binding = $bindings[$key] ?? null; if (!is_array($binding)) { return $match[0]; } return (string)( $binding['name'] ?? $match[0] ); }; /* * {{victim.name}} and the shorter {{victim}} both resolve to the bound * subject's current name. */ $text = preg_replace_callback( '/\{\{\s*([a-z][a-z0-9_]*)\.name\s*\}\}/i', $replace, $text ) ?? $text; $text = preg_replace_callback( '/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/i', $replace, $text ) ?? $text; return $text; } ================================================================================ FILE: _update_backups/v103-20260930-212529/play/includes/storylets.php ================================================================================ root Manual Storylet -> Storylet presentation | -> eligible child Branch -> child presentation -> ... | | Automatic/Chance firing is intentionally not handled here yet. | */ function wayfarerRuntimeLoadStorylet( PDO $pdo, int $gameId, int $storyletId ): ?array { if ($gameId <= 0 || $storyletId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT id, game_id, name, description, parent_storylet_id, branch_label, branch_sort_order, trigger_type, max_occurrences, status, config FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $storyletId, $gameId, ]); $storylet = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$storylet) { return null; } $storylet['_runtime_config'] = wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); return $storylet; } function wayfarerRuntimeStoryletOccurrenceCount( array $runtimeState, int $storyletId ): int { if ($storyletId <= 0) { return 0; } return max( 0, (int)( $runtimeState['storylets'][(string)$storyletId]['occurrences'] ?? 0 ) ); } function wayfarerRuntimeStoryletOccurrenceLimitReached( array $storylet, array $runtimeState ): bool { $maximum = $storylet['max_occurrences'] ?? null; if ( $maximum === null || $maximum === '' || !is_numeric($maximum) ) { return false; } $maximum = (int)$maximum; if ($maximum <= 0) { return false; } return wayfarerRuntimeStoryletOccurrenceCount( $runtimeState, (int)($storylet['id'] ?? 0) ) >= $maximum; } function wayfarerRuntimeStoryletConditionsPass( array $storylet, array &$runtimeState ): bool { $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); $conditions = $config['conditions'] ?? null; $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; return wayfarerConditionsPass( $conditions, $resolver ); } function wayfarerRuntimeStoryletIsEligible( array $storylet, array &$runtimeState ): bool { if ( strtolower( trim( (string)( $storylet['status'] ?? '' ) ) ) !== 'active' ) { return false; } if ( wayfarerRuntimeStoryletOccurrenceLimitReached( $storylet, $runtimeState ) ) { return false; } return wayfarerRuntimeStoryletConditionsPass( $storylet, $runtimeState ); } function wayfarerRuntimeStoryletPlayerPayload( array $storylet, array $runtimeState, bool $asBranch = false ): array { $name = trim( (string)( $storylet['name'] ?? '' ) ); $branchLabel = trim( (string)( $storylet['branch_label'] ?? '' ) ); $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); return [ 'id' => (int)( $storylet['id'] ?? 0 ), 'name' => $name, 'label' => $asBranch && $branchLabel !== '' ? $branchLabel : $name, 'description' => trim( (string)( $storylet['description'] ?? '' ) ), 'priority' => (int)( $config['priority'] ?? 100 ), 'occurrences' => wayfarerRuntimeStoryletOccurrenceCount( $runtimeState, (int)( $storylet['id'] ?? 0 ) ), 'max_occurrences' => $storylet['max_occurrences'] === null || $storylet['max_occurrences'] === '' ? null : (int)$storylet['max_occurrences'], ]; } function wayfarerRuntimeEligibleRootManualStorylets( PDO $pdo, int $gameId, array &$runtimeState ): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare( " SELECT id, game_id, name, description, parent_storylet_id, branch_label, branch_sort_order, trigger_type, max_occurrences, status, config FROM wf_storylets WHERE game_id = ? AND status = 'active' AND trigger_type = 'manual' AND ( parent_storylet_id IS NULL OR parent_storylet_id = 0 ) ORDER BY id " ); $stmt->execute([ $gameId ]); $actions = []; foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $storylet ) { $storylet['_runtime_config'] = wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $runtimeState ) || !wayfarerRuntimeStoryletBindingsAvailable( $pdo, $gameId, $storylet, $runtimeState ) ) { continue; } $actions[] = wayfarerRuntimeStoryletPlayerPayload( $storylet, $runtimeState, false ); } usort( $actions, function ( array $a, array $b ): int { $priority = ((int)($a['priority'] ?? 100)) <=> ((int)($b['priority'] ?? 100)); if ($priority !== 0) { return $priority; } $label = strcasecmp( (string)($a['label'] ?? ''), (string)($b['label'] ?? '') ); if ($label !== 0) { return $label; } return ((int)($a['id'] ?? 0)) <=> ((int)($b['id'] ?? 0)); } ); return $actions; } function wayfarerRuntimeEligibleStoryletBranches( PDO $pdo, int $gameId, int $parentStoryletId, array &$runtimeState ): array { if ( $gameId <= 0 || $parentStoryletId <= 0 ) { return []; } $stmt = $pdo->prepare( " SELECT id, game_id, name, description, parent_storylet_id, branch_label, branch_sort_order, trigger_type, max_occurrences, status, config FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? AND status = 'active' ORDER BY branch_sort_order, name, id " ); $stmt->execute([ $gameId, $parentStoryletId, ]); $branches = []; foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $storylet ) { $storylet['_runtime_config'] = wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $runtimeState ) || !wayfarerRuntimeStoryletBindingsAvailable( $pdo, $gameId, $storylet, $runtimeState ) ) { continue; } $payload = wayfarerRuntimeStoryletPlayerPayload( $storylet, $runtimeState, true ); $payload['branch_sort_order'] = (int)( $storylet['branch_sort_order'] ?? 0 ); $branches[] = $payload; } return $branches; } function wayfarerRuntimeEnsureStoryletTargetState( PDO $pdo, int $gameId, array &$runtimeState ): void { wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $runtimeState ); wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $runtimeState ); wayfarerRuntimeEnsureEntityState( $pdo, $gameId, $runtimeState ); wayfarerRuntimeEnsurePlayerAndGameState( $pdo, $gameId, $runtimeState ); } function wayfarerRuntimeStoryletTraitDefinitions( PDO $pdo, int $gameId, array $runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionConfig = []; if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $raw = $stmt->fetchColumn(); if ($raw !== false) { $regionConfig = wayfarerRuntimeDecodeTraitConfig( $raw ); } } return wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); } /* |-------------------------------------------------------------------------- | Storylet-to-root navigation runtime | WAYFARER_STORYLET_ROOT_CONNECTIONS_V82 |-------------------------------------------------------------------------- */ function wayfarerRuntimeConsumeStoryletTransition( array &$runtimeState ): int { $storyletId = (int)( $runtimeState['runtime']['pending_storylet_transition'] ?? 0 ); if ( isset($runtimeState['runtime']) && is_array($runtimeState['runtime']) ) { unset( $runtimeState['runtime']['pending_storylet_transition'] ); } return max( 0, $storyletId ); } function wayfarerRuntimeEnterLinkedRootStorylet( PDO $pdo, int $gameId, int $storyletId, array &$runtimeState, array $visitedStorylets = [], array $inheritedBindings = [] ): array { $chain = []; $nextStoryletId = $storyletId; while ($nextStoryletId > 0) { if (count($chain) >= 16) { throw new RuntimeException( 'Storylet Continue To chain is too deep.' ); } if ( isset( $visitedStorylets[ (string)$nextStoryletId ] ) ) { throw new RuntimeException( 'Storylet Continue To would create a circular Storylet chain.' ); } $visitedStorylets[ (string)$nextStoryletId ] = true; $linkedStorylet = wayfarerRuntimeLoadStorylet( $pdo, $gameId, $nextStoryletId ); if (!$linkedStorylet) { throw new RuntimeException( 'The linked root Storylet could not be found.' ); } if ( !empty( $linkedStorylet[ 'parent_storylet_id' ] ) ) { throw new RuntimeException( 'Storylet Continue To may target only a root Storylet.' ); } wayfarerRuntimeEnsureStoryletTargetState( $pdo, $gameId, $runtimeState ); if ( !wayfarerRuntimeStoryletIsEligible( $linkedStorylet, $runtimeState ) ) { throw new RuntimeException( 'The linked root Storylet is not currently eligible.' ); } $linkedConfig = $linkedStorylet[ '_runtime_config' ] ?? []; $tickCost = max( 0, (int)( $linkedConfig[ 'tick_cost' ] ?? 0 ) ); if ($tickCost > 0) { throw new RuntimeException( 'Storylet Tick Cost runtime is not implemented yet. Set Tick Cost to 0 for linked Storylets.' ); } /* WAYFARER_PERSISTENT_STORYLET_SUBJECT_V88 */ $linkedPresetBindings = []; if ( isset($inheritedBindings['subject']) && is_array($inheritedBindings['subject']) ) { $subjectScope = (string)( $inheritedBindings['subject']['runtime_scope'] ?? '' ); $subjectRefresh = wayfarerRuntimeStoryletPresetCharacterBinding( $pdo, $gameId, $linkedStorylet, $runtimeState, $subjectScope, 'subject', (string)( $inheritedBindings['subject']['label'] ?? 'Subject' ) ); if (($subjectRefresh['error'] ?? '') !== '') { throw new RuntimeException( (string)$subjectRefresh['error'] ); } if (is_array($subjectRefresh['binding'] ?? null)) { $linkedPresetBindings['subject'] = $subjectRefresh['binding']; } } $bindingResult = wayfarerRuntimeResolveStoryletBindings( $pdo, $gameId, $linkedStorylet, $runtimeState, $linkedPresetBindings ); if ( ($bindingResult['error'] ?? '') !== '' ) { throw new RuntimeException( (string)$bindingResult['error'] ); } $resolvedBindings = is_array( $bindingResult['bindings'] ?? null ) ? $bindingResult['bindings'] : []; /* WAYFARER_PERSISTENT_STORYLET_SUBJECT_V88 */ $inheritedBindings = isset($resolvedBindings['subject']) && is_array($resolvedBindings['subject']) ? [ 'subject' => $resolvedBindings['subject'], ] : []; $effects = wayfarerRuntimeApplyStoryletEffects( $pdo, $gameId, $linkedStorylet, $runtimeState, $resolvedBindings ); $occurrence = wayfarerRuntimeIncrementStoryletOccurrence( $runtimeState, $nextStoryletId ); $chain[] = [ 'storylet_id' => $nextStoryletId, 'bindings' => $resolvedBindings, 'effects' => $effects, 'occurrence' => $occurrence, ]; $nextTransitionId = wayfarerRuntimeConsumeStoryletTransition( $runtimeState ); if ($nextTransitionId <= 0) { return [ 'storylet' => $linkedStorylet, 'bindings' => $resolvedBindings, 'effects' => $effects, 'occurrence' => $occurrence, 'chain' => $chain, ]; } $nextStoryletId = $nextTransitionId; } throw new RuntimeException( 'Storylet Continue To did not resolve to a valid Storylet.' ); } /* * Enter a root Storylet from an internal gameplay event while already inside * the caller's locked runtime transaction. Status Outcomes use this path so * they can supply the affected character as the persistent "subject" binding. * WAYFARER_TRIGGERED_STORYLET_RUNTIME_V88 */ function wayfarerRuntimeEnterTriggeredStoryletInState( PDO $pdo, int $gameId, int $storyletId, array &$runtimeState, array $presetBindings = [], string $trigger = 'event' ): array { if ( isset($runtimeState['runtime']['presentation']) && is_array($runtimeState['runtime']['presentation']) ) { throw new RuntimeException( 'A triggered Storylet cannot open while another presentation is active.' ); } $storylet = wayfarerRuntimeLoadStorylet( $pdo, $gameId, $storyletId ); if (!$storylet) { throw new RuntimeException( 'Triggered Storylet could not be found.' ); } if (!empty($storylet['parent_storylet_id'])) { throw new RuntimeException( 'Triggered Storylets must be root Storylets.' ); } if ( strtolower(trim((string)($storylet['status'] ?? 'draft'))) !== 'active' ) { throw new RuntimeException( 'Triggered Storylet is not Active.' ); } wayfarerRuntimeEnsureStoryletTargetState( $pdo, $gameId, $runtimeState ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $runtimeState ) ) { throw new RuntimeException( 'Triggered Storylet is not currently eligible.' ); } $config = $storylet['_runtime_config'] ?? []; if ( max(0, (int)($config['tick_cost'] ?? 0)) > 0 ) { throw new RuntimeException( 'Triggered Storylet Tick Cost runtime is not implemented yet. Set Tick Cost to 0.' ); } $bindingResult = wayfarerRuntimeResolveStoryletBindings( $pdo, $gameId, $storylet, $runtimeState, $presetBindings ); if (($bindingResult['error'] ?? '') !== '') { throw new RuntimeException( (string)$bindingResult['error'] ); } $resolvedBindings = is_array($bindingResult['bindings'] ?? null) ? $bindingResult['bindings'] : []; $effects = wayfarerRuntimeApplyStoryletEffects( $pdo, $gameId, $storylet, $runtimeState, $resolvedBindings ); $occurrence = wayfarerRuntimeIncrementStoryletOccurrence( $runtimeState, $storyletId ); $presentationStorylet = $storylet; $presentationBindings = $resolvedBindings; $presentationOccurrence = $occurrence; $presentationTrigger = strtolower(trim($trigger)); $transitionId = wayfarerRuntimeConsumeStoryletTransition( $runtimeState ); if ($transitionId > 0) { $linkedResult = wayfarerRuntimeEnterLinkedRootStorylet( $pdo, $gameId, $transitionId, $runtimeState, [ (string)$storyletId => true, ], isset($resolvedBindings['subject']) && is_array($resolvedBindings['subject']) ? [ 'subject' => $resolvedBindings['subject'], ] : [] ); $presentationStorylet = $linkedResult['storylet']; $presentationBindings = $linkedResult['bindings']; $presentationOccurrence = $linkedResult['occurrence']; $presentationTrigger = 'transition'; } $presentationStoryletId = (int)($presentationStorylet['id'] ?? 0); $runtimeState['runtime']['presentation'] = [ 'type' => 'storylet', 'id' => $presentationStoryletId, 'phase' => 'presentation', 'trigger' => $presentationTrigger, 'tick' => (int)($runtimeState['runtime']['tick'] ?? 0), 'bindings' => $presentationBindings, ]; $runtimeState['runtime']['last_storylet_entry'] = [ 'storylet_id' => $presentationStoryletId, 'parent_storylet_id' => (int)($presentationStorylet['parent_storylet_id'] ?? 0), 'entry_mode' => $presentationTrigger, 'occurrence' => $presentationOccurrence, 'bindings' => $presentationBindings, 'tick' => (int)($runtimeState['runtime']['tick'] ?? 0), ]; return [ 'storylet' => $presentationStorylet, 'bindings' => $presentationBindings, 'effects' => $effects, 'occurrence' => $presentationOccurrence, 'presentation' => $runtimeState['runtime']['presentation'], ]; } function wayfarerRuntimeApplyStoryletEffects( PDO $pdo, int $gameId, array $storylet, array &$runtimeState, array $bindings = [] ): array { $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); $effects = $config['effects'] ?? []; if (!is_array($effects)) { $effects = []; } $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $traitDefinitions = wayfarerRuntimeStoryletTraitDefinitions( $pdo, $gameId, $runtimeState ); wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $regionId ); $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { throw new RuntimeException( 'Malformed Storylet Effect.' ); } $materialized = wayfarerRuntimeMaterializeStoryletBindingEffect( $effect, $bindings ); if ( ($materialized['error'] ?? '') !== '' ) { throw new RuntimeException( 'Storylet Binding Effect failed: ' . (string)$materialized['error'] ); } $runtimeEffect = $materialized['effect'] ?? $effect; if ( (string)( $runtimeEffect['target_scope'] ?? '' ) === 'storylet' && (string)( $runtimeEffect['target_type'] ?? '' ) === 'transition' ) { $selection = wayfarerRuntimeSelectEffectBranch( $runtimeState, $runtimeEffect, $regionId ); if ( ($selection['error'] ?? '') !== '' ) { throw new RuntimeException( 'Storylet Continue To Effect failed: ' . (string)$selection['error'] ); } $matched = !empty( $selection['matched'] ); $branch = is_array( $selection['branch'] ?? null ) ? $selection['branch'] : []; $operation = strtolower( trim( (string)( $branch['operation'] ?? 'none' ) ) ); $targetStoryletId = (int)( $runtimeEffect['target_id'] ?? 0 ); $applied = $matched && $operation === 'continue'; if ($applied) { if ($targetStoryletId <= 0) { throw new RuntimeException( 'Storylet Continue To Effect has an invalid destination.' ); } $existingTransition = (int)( $runtimeState['runtime']['pending_storylet_transition'] ?? 0 ); if ( $existingTransition > 0 && $existingTransition !== $targetStoryletId ) { throw new RuntimeException( 'More than one Storylet Continue To Effect matched.' ); } $runtimeState['runtime']['pending_storylet_transition'] = $targetStoryletId; } $reports[] = [ 'scope' => 'storylet', 'type' => 'transition', 'target_id' => (string)$targetStoryletId, 'operation' => $operation, 'applied' => $applied, 'matched' => $matched, 'branch_index' => $selection['branch_index'] ?? null, 'error' => '', ]; continue; } $report = wayfarerApplyRuntimeOneTimeEffect( $runtimeState, $runtimeEffect, $regionId, $traitDefinitions ); if (!empty($report['error'])) { throw new RuntimeException( 'Storylet Effect failed: ' . (string)$report['error'] ); } $reports[] = $report; /* WAYFARER_STATUS_STORYLET_ACTIVATION_V92 */ if ( strtolower(trim((string)($report['type'] ?? ''))) === 'status' && !empty($report['applied']) ) { $statusActivationReports = strtolower(trim((string)($report['scope'] ?? ''))) === 'party' && isset($report['results']) && is_array($report['results']) ? array_values($report['results']) : [$report]; foreach ($statusActivationReports as $statusActivationReport) { if ( !is_array($statusActivationReport) || empty($statusActivationReport['applied']) || (int)($statusActivationReport['before'] ?? 0) !== 0 || (int)($statusActivationReport['after'] ?? 0) !== 1 ) { continue; } $activation = wayfarerRuntimeInitializeActivatedStatus( $pdo, $gameId, $runtimeState, (string)($statusActivationReport['scope'] ?? ''), (int)($statusActivationReport['target_id'] ?? 0), $regionId, $traitDefinitions ); if (($activation['error'] ?? '') !== '') { throw new RuntimeException( 'Storylet Status activation failed: ' . (string)$activation['error'] ); } foreach ( isset($activation['entry_effects']) && is_array($activation['entry_effects']) ? $activation['entry_effects'] : [] as $entryReport ) { if (!is_array($entryReport)) { continue; } if (!empty($entryReport['error'])) { throw new RuntimeException( 'Storylet Status On Enter Effect failed: ' . (string)$entryReport['error'] ); } $entryReport['status_activation_entry_effect'] = true; $reports[] = $entryReport; } } } } /* WAYFARER_STORYLET_EFFECT_FEEDBACK_V70 */ if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_storylet_effects'] = [ 'storylet_id' => (int)( $storylet['id'] ?? 0 ), 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), 'reports' => $reports, 'changes' => wayfarerRuntimeBuildEffectChangeEvents( $pdo, $gameId, $runtimeState, $reports ), ]; return $reports; } function wayfarerRuntimeIncrementStoryletOccurrence( array &$runtimeState, int $storyletId ): int { if ( !isset($runtimeState['storylets']) || !is_array($runtimeState['storylets']) ) { $runtimeState['storylets'] = []; } $key = (string)$storyletId; if ( !isset($runtimeState['storylets'][$key]) || !is_array($runtimeState['storylets'][$key]) ) { $runtimeState['storylets'][$key] = []; } $count = wayfarerRuntimeStoryletOccurrenceCount( $runtimeState, $storyletId ) + 1; $runtimeState['storylets'][$key]['occurrences'] = $count; $runtimeState['storylets'][$key]['last_entered_tick'] = (int)( $runtimeState['runtime']['tick'] ?? 0 ); return $count; } function wayfarerRuntimeEnterStorylet( PDO $pdo, int $gameUserId, int $storyletId, string $entryMode = 'action', int $expectedParentStoryletId = 0 ): array { $entryMode = strtolower( trim($entryMode) ); if ( $entryMode !== 'action' && $entryMode !== 'branch' ) { throw new RuntimeException( 'Invalid Storylet entry mode.' ); } try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $gameId = (int)$row['game_id']; $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } if ( isset($state['runtime']['ending']) && is_array($state['runtime']['ending']) ) { throw new RuntimeException( 'This game has ended.' ); } $activePresentation = $state['runtime']['presentation'] ?? null; $storylet = wayfarerRuntimeLoadStorylet( $pdo, $gameId, $storyletId ); if (!$storylet) { throw new RuntimeException( 'Storylet not found.' ); } if ($entryMode === 'action') { if (is_array($activePresentation)) { throw new RuntimeException( 'Continue the current presentation before choosing an Action.' ); } if ( !empty($storylet['parent_storylet_id']) || strtolower( trim( (string)( $storylet['trigger_type'] ?? '' ) ) ) !== 'manual' ) { throw new RuntimeException( 'That Storylet is not an available root Manual Action.' ); } } else { if ( !is_array($activePresentation) || (string)( $activePresentation['type'] ?? '' ) !== 'storylet' || (int)( $activePresentation['id'] ?? 0 ) <= 0 ) { throw new RuntimeException( 'There is no active Storylet Branch to choose from.' ); } $parentStoryletId = (int)( $activePresentation['id'] ?? 0 ); if ( $expectedParentStoryletId > 0 && $parentStoryletId !== $expectedParentStoryletId ) { throw new RuntimeException( 'The active Storylet changed before the Branch was selected.' ); } if ( (int)( $storylet['parent_storylet_id'] ?? 0 ) !== $parentStoryletId ) { throw new RuntimeException( 'That Storylet is not a Branch of the active Storylet.' ); } } wayfarerRuntimeEnsureStoryletTargetState( $pdo, $gameId, $state ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $state ) || ( $entryMode === 'action' && !wayfarerRuntimeStoryletBindingsAvailable( $pdo, $gameId, $storylet, $state ) ) ) { throw new RuntimeException( 'That Storylet is no longer eligible.' ); } $config = $storylet['_runtime_config'] ?? []; $tickCost = max( 0, (int)( $config['tick_cost'] ?? 0 ) ); /* * V59 proves interactive Storylets before refactoring the normal tick * pipeline into a reusable "consume N ticks" helper. Reject non-zero * cost rather than silently ignoring authored time passage. */ if ($tickCost > 0) { throw new RuntimeException( 'Storylet Tick Cost runtime is not implemented yet. Set Tick Cost to 0 for V59.' ); } /* WAYFARER_STORYLET_BINDING_RUNTIME_V79 */ /* WAYFARER_PERSISTENT_STORYLET_SUBJECT_V88 */ $presetBindings = []; if ( $entryMode === 'branch' && isset($activePresentation['bindings']['subject']) && is_array($activePresentation['bindings']['subject']) ) { $subjectScope = (string)( $activePresentation['bindings']['subject']['runtime_scope'] ?? '' ); $subjectRefresh = wayfarerRuntimeStoryletPresetCharacterBinding( $pdo, $gameId, $storylet, $state, $subjectScope, 'subject', (string)( $activePresentation['bindings']['subject']['label'] ?? 'Subject' ) ); if (($subjectRefresh['error'] ?? '') !== '') { throw new RuntimeException( (string)$subjectRefresh['error'] ); } if (is_array($subjectRefresh['binding'] ?? null)) { $presetBindings['subject'] = $subjectRefresh['binding']; } } $bindingResult = wayfarerRuntimeResolveStoryletBindings( $pdo, $gameId, $storylet, $state, $presetBindings ); if ( ($bindingResult['error'] ?? '') !== '' ) { throw new RuntimeException( (string)$bindingResult['error'] ); } $resolvedBindings = is_array( $bindingResult['bindings'] ?? null ) ? $bindingResult['bindings'] : []; $effects = wayfarerRuntimeApplyStoryletEffects( $pdo, $gameId, $storylet, $state, $resolvedBindings ); $occurrence = wayfarerRuntimeIncrementStoryletOccurrence( $state, $storyletId ); if ($entryMode === 'branch') { $state['runtime']['last_presentation'] = $activePresentation; } $presentationStorylet = $storylet; $presentationBindings = $resolvedBindings; $presentationOccurrence = $occurrence; $presentationTrigger = $entryMode; $transitionId = wayfarerRuntimeConsumeStoryletTransition( $state ); if ($transitionId > 0) { $linkedResult = wayfarerRuntimeEnterLinkedRootStorylet( $pdo, $gameId, $transitionId, $state, [ (string)$storyletId => true, ], isset($resolvedBindings['subject']) && is_array($resolvedBindings['subject']) ? [ 'subject' => $resolvedBindings['subject'], ] : [] ); $presentationStorylet = $linkedResult['storylet']; $presentationBindings = $linkedResult['bindings']; $presentationOccurrence = $linkedResult['occurrence']; $presentationTrigger = 'transition'; $state['runtime']['last_storylet_transition'] = [ 'from_storylet_id' => $storyletId, 'to_storylet_id' => (int)( $presentationStorylet['id'] ?? 0 ), 'chain' => $linkedResult['chain'], 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } else { unset( $state['runtime']['last_storylet_transition'] ); } $presentationStoryletId = (int)( $presentationStorylet['id'] ?? 0 ); $state['runtime']['presentation'] = [ 'type' => 'storylet', 'id' => $presentationStoryletId, 'phase' => 'presentation', 'trigger' => $presentationTrigger, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), 'bindings' => $presentationBindings, ]; /* WAYFARER_LOCATION_STORYLET_TRANSITIONS_V102 */ if ( $entryMode === 'branch' && is_array($activePresentation) && isset($activePresentation['source_context']) && is_array($activePresentation['source_context']) ) { $state['runtime']['presentation']['source_context'] = $activePresentation['source_context']; if (isset($activePresentation['location_trigger'])) { $state['runtime']['presentation']['location_trigger'] = (string)$activePresentation['location_trigger']; } } $state['runtime']['last_storylet_entry'] = [ 'storylet_id' => $presentationStoryletId, 'parent_storylet_id' => (int)( $presentationStorylet['parent_storylet_id'] ?? 0 ), 'entry_mode' => $presentationTrigger, 'occurrence' => $presentationOccurrence, 'bindings' => $presentationBindings, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; $ending = wayfarerRuntimeEvaluateGameEndings( $pdo, $gameId, $state ); $encoded = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encoded, $gameUserId, ]); $pdo->commit(); $presentation = $state['runtime']['presentation'] ?? null; return [ 'game_user_id' => (int)$row['id'], 'game_id' => $gameId, 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, $gameId, $state ), 'presentation' => is_array($presentation) ? $presentation : null, 'ending' => $ending, 'effects' => $effects, 'occurrence' => $presentationOccurrence, 'actions' => $ending === null && !is_array($presentation) ? wayfarerRuntimeEligibleRootManualStorylets( $pdo, $gameId, $state ) : [], ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: _update_backups/v71-20260928-133621/play/api/tick.php ================================================================================ false, 'error' => 'POST required.', ]); exit; } if (session_status() !== PHP_SESSION_ACTIVE) { session_start(); } require_once __DIR__ . '/../../config/database.php'; require_once __DIR__ . '/../includes/runtime.php'; function wayfarerTickRespond( int $status, array $payload ): void { http_response_code($status); echo json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); exit; } try { $rawInput = file_get_contents( 'php://input' ); $input = json_decode( $rawInput ?: '{}', true ); if (!is_array($input)) { wayfarerTickRespond( 400, [ 'ok' => false, 'error' => 'Invalid JSON request.', ] ); } $csrfToken = isset($input['csrf_token']) ? (string)$input['csrf_token'] : ''; $sessionToken = isset($_SESSION['wayfarer_tick_csrf']) ? (string)$_SESSION['wayfarer_tick_csrf'] : ''; if ( $csrfToken === '' || $sessionToken === '' || !hash_equals( $sessionToken, $csrfToken ) ) { wayfarerTickRespond( 403, [ 'ok' => false, 'error' => 'Invalid tick token.', ] ); } $requestedId = isset($input['game_user_id']) ? (int)$input['game_user_id'] : null; $gameUserId = wayfarerRuntimeResolveGameUserId( $pdo, $requestedId ); $result = wayfarerRuntimeAdvanceTick( $pdo, $gameUserId ); wayfarerTickRespond( 200, [ 'ok' => true, 'tick' => $result['tick'], 'game_user_id' => $result['game_user_id'], 'state' => $result['state'], /* WAYFARER_PLAYER_CONTEXT_API_V46_1 */ 'context' => $result['context'] ?? null, /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 * The browser stops automatic advancement immediately when the * runtime reports an interrupting presentation. */ 'pause' => !empty( $result['pause'] ), 'pause_reason' => (string)( $result['pause_reason'] ?? '' ), 'presentation' => $result['presentation'] ?? null, /* WAYFARER_GAME_ENDINGS_TICK_API_V45 */ 'ending' => $result['ending'] ?? null, /* WAYFARER_STORYLET_ACTIONS_TICK_API_V59 */ 'actions' => $result['actions'] ?? [], /* WAYFARER_TRAVEL_TIME_HUD_V61 */ 'travel' => $result['travel'] ?? null, ] ); } catch (Throwable $exception) { wayfarerTickRespond( 500, [ 'ok' => false, 'error' => $exception->getMessage(), ] ); } ================================================================================ FILE: _update_backups/v71-20260928-133621/play/assets/play.js ================================================================================ /* |-------------------------------------------------------------------------- | PLAYER RUNTIME CONTROLLER | WAYFARER_ACTION_SCREEN_V39 | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | Runtime modes currently exposed to the player: | | presentation -> Action -> running navigation -> presentation/Action | | Presentation is persisted by PHP. Automatic tick advancement cannot continue | while a presentation is active. | */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; if (!config) { return; } const body = document.body; const tickValue = document.getElementById( 'wayfarer-tick-value' ); const statusValue = document.getElementById( 'wayfarer-tick-status' ); const message = document.getElementById( 'wayfarer-tick-message' ); const onceButton = document.getElementById( 'wayfarer-tick-once' ); const autoButton = document.getElementById( 'wayfarer-tick-auto' ); const authorPauseButton = document.getElementById( 'wayfarer-tick-pause' ); const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); /* WAYFARER_PLAYER_HUD_V64 */ const playerHud = document.querySelector( '[data-wayfarer-hud]' ); /* WAYFARER_TRAVEL_TIME_HUD_V61 */ const travelTime = document.querySelector( '[data-wayfarer-travel-time]' ); const travelTimeSeparator = document.querySelector( '[data-wayfarer-travel-time-separator]' ); const travelNext = document.querySelector( '[data-wayfarer-travel-next]' ); const continueTravelButton = document.querySelector( '[data-wayfarer-continue-travel]' ); const playerPauseButton = document.querySelector( '[data-wayfarer-pause-travel]' ); /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Action Mode owns one physical card. Inventory, Status, and future * utility/context views temporarily replace the normal action heading/list * inside that card without becoming persisted runtime modes. */ const actionHeading = actionScreen ? actionScreen.querySelector( '.wayfarer-action-heading' ) : null; const actionList = actionScreen ? actionScreen.querySelector( '[data-wayfarer-action-list]' ) : null; const actionSubviewPanels = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ) : []; const actionViewOpeners = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-open-action-view]' ) ) : []; const actionViewClosers = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-close-action-view]' ) ) : []; /* * Persisted narrative presentation. */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); const presentationContinueButton = presentationScreen ? presentationScreen.querySelector( '[data-wayfarer-presentation-continue]' ) : null; let running = false; let requestInFlight = false; let presentationRequestInFlight = false; let storyletRequestInFlight = false; let timeoutId = null; let reloadWhenIdle = false; let presentationActive = Boolean( presentationScreen && !presentationScreen.hidden ); function setMessage( text, isError = false ) { if (!message) { return; } message.textContent = text; message.style.color = isError ? 'red' : ''; } function emitModeEvent() { let mode = 'paused'; if (presentationActive) { mode = 'presentation'; } else if (running) { mode = 'running'; } window.dispatchEvent( new CustomEvent( 'wayfarer:runtime-mode', { detail: { mode: mode } } ) ); /* * Keep the v39 event names available for existing development hooks. */ window.dispatchEvent( new CustomEvent( running ? 'wayfarer:runtime-running' : 'wayfarer:runtime-paused', { detail: { mode: mode } } ) ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Generic in-card view switcher. Future Map / Shop / other views can use * the same data-wayfarer-open-action-view + data-wayfarer-action-view * contract without adding another page-level layout system. */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Resolve available views at the moment navigation occurs instead of * relying on the V48 page-load snapshot. This supports nested Entity * detail screens and future context views added to the same Action card. */ function setActionView(viewName = 'actions') { if (!actionScreen) { return; } const requested = String( viewName || 'actions' ); const showActions = requested === 'actions'; const panels = Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ); let matched = showActions; panels.forEach( function (panel) { const active = panel.dataset .wayfarerActionView === requested; panel.hidden = !active; if (active) { matched = true; } } ); /* * Fail safely if a malformed/removed view name is requested. */ if (!matched) { panels.forEach( function (panel) { panel.hidden = true; } ); if (actionHeading) { actionHeading.hidden = false; } if (actionList) { actionList.hidden = false; } return; } if (actionHeading) { actionHeading.hidden = !showActions; } if (actionList) { actionList.hidden = !showActions; } } function renderControls() { if (statusValue) { statusValue.textContent = presentationActive ? 'Presentation' : ( running ? 'Running' : 'Paused' ); } if (onceButton) { onceButton.disabled = running || requestInFlight || presentationActive; } if (autoButton) { autoButton.disabled = running || requestInFlight || presentationActive; } if (authorPauseButton) { authorPauseButton.disabled = !running; } if (continueTravelButton) { continueTravelButton.disabled = running || requestInFlight || presentationActive; } document .querySelectorAll( '[data-wayfarer-storylet-action-id], ' + '[data-wayfarer-storylet-branch-id]' ) .forEach( function (button) { button.disabled = requestInFlight || presentationRequestInFlight || storyletRequestInFlight || ( button.hasAttribute( 'data-wayfarer-storylet-action-id' ) && (running || presentationActive) ); } ); if (playerPauseButton) { playerPauseButton.disabled = !running; } if (actionScreen) { actionScreen.hidden = running || presentationActive; } if (travelControls) { travelControls.hidden = !running || presentationActive; } /* WAYFARER_PLAYER_HUD_V64 */ if (playerHud) { playerHud.hidden = presentationActive; } if (presentationScreen) { presentationScreen.hidden = !presentationActive; presentationScreen.setAttribute( 'aria-hidden', presentationActive ? 'false' : 'true' ); } body.classList.toggle( 'wayfarer-runtime-running', running ); body.classList.toggle( 'wayfarer-runtime-paused', !running && !presentationActive ); body.classList.toggle( 'wayfarer-runtime-presentation', presentationActive ); } /* * WAYFARER_LIVE_ACTION_CONTEXT_V46_1 * * Action Mode must reflect the current runtime position, not the page's * original server-rendered context. */ function renderPlayerContext(context) { if ( !context || typeof context !== 'object' ) { return; } const heading = document.querySelector( '[data-wayfarer-action-context-name]' ); const parent = document.querySelector( '[data-wayfarer-action-parent-context]' ); if (heading) { const name = String( context.context_name || 'Actions' ).trim(); heading.textContent = name || 'Actions'; } if (parent) { const parentText = String( context.parent_context || '' ).trim(); parent.textContent = parentText; parent.hidden = parentText === ''; } } /* WAYFARER_TRAVEL_TIME_HUD_RENDER_V61 */ function renderTravelStatus(travel) { if (!travel || typeof travel !== 'object') { return; } const timeLabel = String(travel.time_label || '').trim(); if (travelTime) { travelTime.textContent = timeLabel; travelTime.hidden = timeLabel === ''; } if (travelTimeSeparator) { travelTimeSeparator.hidden = timeLabel === ''; } const nextLabel = String(travel.next_label || '').trim(); if (travelNext) { travelNext.textContent = nextLabel === '' ? '' : 'Next: ' + nextLabel; travelNext.hidden = nextLabel === ''; } } function displayRuntimeValue(value) { if (typeof value === 'boolean') { return value ? 'True' : 'False'; } if ( value === null || value === undefined ) { return 'null'; } if (typeof value === 'object') { return JSON.stringify( value ); } return String( value ); } /* * WAYFARER_STORYLET_ACTIONS_LIVE_V59 * * Tick/presentation APIs return the currently eligible root Manual * Storylets so Action Mode does not depend on the page-load snapshot. */ function renderStoryletActions(actions) { if (!actionScreen) { return; } const host = actionScreen.querySelector( '[data-wayfarer-storylet-actions]' ); if (!host) { return; } const normalized = Array.isArray(actions) ? actions : []; host.innerHTML = ''; normalized.forEach( function (action) { const id = Number( action && action.id ? action.id : 0 ); if (id <= 0) { return; } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-action-button'; button.dataset.wayfarerStoryletActionId = String(id); button.textContent = String( action.label || action.name || 'Storylet' ); host.appendChild( button ); } ); const empty = actionScreen.querySelector( '[data-wayfarer-action-empty]' ); if (empty) { const canTravel = actionScreen.dataset.wayfarerCanTravel === '1'; empty.hidden = canTravel || host.children.length > 0; } renderControls(); } /* * WAYFARER_PLAYER_HUD_V64 * * HUD definitions remain Region-authored/server-rendered. Tick responses * already carry canonical state, so progress bars can update without adding * another API payload. */ function renderHudState(state) { if ( !playerHud || !state || typeof state !== 'object' ) { return; } playerHud .querySelectorAll( '[data-wayfarer-hud-component]' ) .forEach( function (component) { if ( component.dataset .wayfarerHudType !== 'progress_bar' ) { return; } const scope = String( component.dataset .wayfarerHudScope || '' ); const sourceId = String( component.dataset .wayfarerHudSourceId || '' ); const regionId = String( component.dataset .wayfarerHudRegionId || '' ); let traits = null; if ( scope === 'game' ) { traits = state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : null; } else if ( scope === 'region' ) { traits = state.regions && state.regions[regionId] && state.regions[regionId].traits && typeof state.regions[regionId].traits === 'object' ? state.regions[regionId].traits : null; } if ( !traits || !Object.prototype .hasOwnProperty .call( traits, sourceId ) ) { return; } const value = Number( traits[sourceId] ); const min = Number( component.dataset .wayfarerHudMin ); const max = Number( component.dataset .wayfarerHudMax ); if ( !Number.isFinite(value) || !Number.isFinite(min) || !Number.isFinite(max) || max <= min ) { return; } const percent = Math.max( 0, Math.min( 100, ( (value - min) / (max - min) ) * 100 ) ); const fill = component.querySelector( '[data-wayfarer-hud-fill]' ); if (fill) { fill.style.width = percent + '%'; } const meter = component.querySelector( '[data-wayfarer-hud-meter]' ); if (meter) { meter.setAttribute( 'aria-valuenow', String(value) ); } const valueNode = component.querySelector( '[data-wayfarer-hud-value]' ); if (valueNode) { valueNode.textContent = String(value); } } ); } function renderRuntimeState(state) { if ( !state || typeof state !== 'object' ) { return; } renderHudState( state ); if ( state.navigation && state.navigation.position !== undefined ) { const position = String( state.navigation.position ); const authorPosition = document.getElementById( 'wayfarer-author-position' ); if (authorPosition) { authorPosition.textContent = position; } } const gameTraits = state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : {}; document .querySelectorAll( '[data-wayfarer-game-trait-value]' ) .forEach( function (cell) { const traitId = cell.dataset .wayfarerGameTraitValue; if ( Object.prototype .hasOwnProperty .call( gameTraits, traitId ) ) { cell.textContent = displayRuntimeValue( gameTraits[traitId] ); } } ); document .querySelectorAll( '[data-wayfarer-region-trait-value]' ) .forEach( function (cell) { const regionId = cell.dataset .wayfarerRegionId; const traitId = cell.dataset .wayfarerRegionTraitValue; const traits = state.regions && state.regions[regionId] && state.regions[regionId].traits && typeof state.regions[regionId].traits === 'object' ? state.regions[regionId].traits : null; if ( traits && Object.prototype .hasOwnProperty .call( traits, traitId ) ) { cell.textContent = displayRuntimeValue( traits[traitId] ); } } ); const debug = document.getElementById( 'wayfarer-runtime-debug' ); if (debug) { debug.textContent = JSON.stringify( state, null, 2 ); } } async function requestTick() { if ( requestInFlight || presentationActive ) { return null; } requestInFlight = true; renderControls(); try { const response = await fetch( config.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Tick endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Tick request failed.' ); } if (tickValue) { tickValue.textContent = String( data.tick ); } renderRuntimeState( data.state ); renderPlayerContext( data.context ); renderTravelStatus( data.travel ); renderStoryletActions( data.actions ); setMessage( 'Tick ' + data.tick + ' saved.' ); if (data.pause === true) { /* * Suppress Action Mode during the brief interval before reload. * The server-rendered presentation will exist after reload. */ presentationActive = true; pauseRuntime( data.pause_reason || 'Travel interrupted.', true ); } return data; } catch (error) { pauseRuntime( error instanceof Error ? error.message : 'Tick request failed.' ); setMessage( error instanceof Error ? error.message : 'Tick request failed.', true ); return null; } finally { requestInFlight = false; renderControls(); if (reloadWhenIdle) { reloadWhenIdle = false; window.location.reload(); } } } async function runAutomaticTick() { if ( !running || presentationActive ) { return; } const result = await requestTick(); if ( !running || presentationActive || result === null ) { return; } timeoutId = window.setTimeout( runAutomaticTick, Number( config.intervalMs ) || 1500 ); } function startRuntime() { if ( running || requestInFlight || presentationActive ) { return; } /* * Travel always leaves inspection/context views and returns the card to * its normal Action view for the next pause. */ setActionView( 'actions' ); running = true; setMessage( 'Automatic advancement started.' ); renderControls(); emitModeEvent(); runAutomaticTick(); } /* * WAYFARER_PRESENTATION_TRANSITION_V63 * * Cover the current DOM before a server-rendered Presentation replaces it. * This prevents the previous Location/Storylet copy from being painted for * a frame during the navigation boundary. */ function beginPresentationTransition() { body.classList.add( 'wayfarer-presentation-transitioning' ); } function pauseRuntime( reason = 'Automatic advancement paused.', reloadForPresentation = false ) { const wasRunning = running; running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } setMessage( reason ); if (wasRunning) { setActionView( 'actions' ); } renderControls(); if (wasRunning) { emitModeEvent(); } if (reloadForPresentation) { /* WAYFARER_PRESENTATION_TRANSITION_V63 */ beginPresentationTransition(); if (requestInFlight) { reloadWhenIdle = true; } else { window.location.reload(); } } } /* * WAYFARER_STORYLET_ACTION_RUNTIME_V59 */ async function requestStorylet( storyletId, entryMode = 'action', parentStoryletId = 0 ) { if ( storyletRequestInFlight || requestInFlight || presentationRequestInFlight ) { return; } storyletId = Number(storyletId || 0); parentStoryletId = Number(parentStoryletId || 0); if (storyletId <= 0) { return; } storyletRequestInFlight = true; renderControls(); try { const response = await fetch( config.storyletEndpoint || 'api/storylet.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, storylet_id: storyletId, entry_mode: entryMode, parent_storylet_id: parentStoryletId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Storylet endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not enter Storylet.' ); } /* * Storylet presentation/Branch markup is server-rendered. Reload * only after the runtime transaction succeeds, so Effects cannot be * repeated by a failed or duplicate browser transition. */ /* WAYFARER_PRESENTATION_TRANSITION_V63 */ beginPresentationTransition(); window.location.reload(); } catch (error) { window.alert( error instanceof Error ? error.message : 'Could not enter Storylet.' ); } finally { storyletRequestInFlight = false; renderControls(); } } async function dismissPresentation() { if ( !presentationActive || !presentationScreen || presentationRequestInFlight ) { return; } presentationRequestInFlight = true; if (presentationContinueButton) { presentationContinueButton.disabled = true; } const presentationType = presentationScreen.dataset .wayfarerPresentationType || ''; const presentationId = Number( presentationScreen.dataset .wayfarerPresentationId || 0 ); try { const response = await fetch( config.presentationEndpoint || 'api/presentation.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, type: presentationType, id: presentationId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Presentation endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not continue.' ); } presentationActive = false; setActionView( 'actions' ); renderRuntimeState( data.state ); renderPlayerContext( data.context ); renderStoryletActions( data.actions ); renderControls(); emitModeEvent(); /* * Place keyboard focus on the first available Action so the * transition works cleanly for keyboard players too. */ const firstAction = actionScreen ? actionScreen.querySelector( 'button:not([disabled]), a[href]' ) : null; if ( firstAction && typeof firstAction.focus === 'function' ) { firstAction.focus(); } } catch (error) { if (presentationContinueButton) { presentationContinueButton.disabled = false; } window.alert( error instanceof Error ? error.message : 'Could not continue.' ); } finally { presentationRequestInFlight = false; /* WAYFARER_PRESENTATION_CONTROL_RESET_V62 * * dismissPresentation() previously refreshed Action controls while * presentationRequestInFlight was still true. Newly-rendered Manual * Storylet buttons were therefore left disabled after a Location or * Storylet presentation was dismissed. Re-render once the request * flag has actually been cleared. */ renderControls(); if ( presentationContinueButton && presentationActive ) { presentationContinueButton.disabled = false; } } } if (onceButton) { onceButton.addEventListener( 'click', async function () { if ( running || presentationActive ) { return; } setMessage(''); await requestTick(); } ); } if (autoButton) { autoButton.addEventListener( 'click', startRuntime ); } if (authorPauseButton) { authorPauseButton.addEventListener( 'click', function () { pauseRuntime( 'Automatic advancement paused.' ); } ); } if (continueTravelButton) { continueTravelButton.addEventListener( 'click', startRuntime ); } if (playerPauseButton) { playerPauseButton.addEventListener( 'click', function () { /* * WAYFARER_SMOOTH_MANUAL_PAUSE_V46_2 * * v46.1 keeps Action context current in-place, so manual Pause * no longer needs a full page reload. */ pauseRuntime( 'Travel paused.' ); } ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Ensure V50 detail panels physically belong to the Action card. This is * harmless when the PHP view already placed them correctly, and repairs * any earlier V50 markup that landed just outside the host. */ if (actionScreen) { document .querySelectorAll( '.wayfarer-entity-detail-view' ) .forEach( function (panel) { if ( !actionScreen.contains( panel ) ) { actionScreen.appendChild( panel ); } } ); /* * Delegate navigation from the Action card itself. Any current or * future control using the generic data attributes automatically * participates in the same router. */ actionScreen.addEventListener( 'click', function (event) { const storyletAction = event.target.closest( '[data-wayfarer-storylet-action-id]' ); if ( storyletAction && actionScreen.contains( storyletAction ) ) { if ( running || presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( storyletAction.dataset .wayfarerStoryletActionId, 'action', 0 ); return; } const opener = event.target.closest( '[data-wayfarer-open-action-view]' ); if ( opener && actionScreen.contains( opener ) ) { if ( running || presentationActive ) { return; } event.preventDefault(); setActionView( opener.dataset .wayfarerOpenActionView || 'actions' ); return; } const closer = event.target.closest( '[data-wayfarer-close-action-view]' ); if ( !closer || !actionScreen.contains( closer ) ) { return; } event.preventDefault(); const returnView = closer.dataset .wayfarerReturnActionView || 'actions'; setActionView( returnView ); let focusTarget = null; if (returnView === 'actions') { focusTarget = actionList ? actionList.querySelector( 'button:not([disabled]), a[href]' ) : null; } else { const parentPanel = actionScreen.querySelector( '[data-wayfarer-action-view="' + returnView + '"]' ); focusTarget = parentPanel ? parentPanel.querySelector( '[data-wayfarer-open-action-view], button:not([disabled]), a[href]' ) : null; } if ( focusTarget && typeof focusTarget.focus === 'function' ) { focusTarget.focus(); } } ); } /* WAYFARER_STORYLET_BRANCH_SELECTION_V59 */ document.addEventListener( 'click', function (event) { const branch = event.target.closest( '[data-wayfarer-storylet-branch-id]' ); if (!branch) { return; } if ( !presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( branch.dataset .wayfarerStoryletBranchId, 'branch', branch.dataset .wayfarerStoryletParentId ); } ); if (presentationContinueButton) { presentationContinueButton.addEventListener( 'click', dismissPresentation ); } document.addEventListener( 'keydown', function (event) { if ( !presentationActive || presentationRequestInFlight || storyletRequestInFlight || !presentationContinueButton ) { return; } const target = event.target; if ( target && target.matches && target.matches( 'input, textarea, select, button, a[href], [contenteditable="true"]' ) ) { return; } if ( event.key !== 'Enter' && event.key !== ' ' ) { return; } event.preventDefault(); dismissPresentation(); } ); window.addEventListener( 'pagehide', function () { running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } } ); window.wayfarerRuntimeController = { start: startRuntime, pause: pauseRuntime, dismissPresentation: dismissPresentation, enterStorylet: requestStorylet, isRunning: function () { return running; }, mode: function () { if (presentationActive) { return 'presentation'; } return running ? 'running' : 'paused'; } }; setActionView( 'actions' ); renderControls(); emitModeEvent(); })(); /* |-------------------------------------------------------------------------- | WAYFARER PLAY SHELL v20 |-------------------------------------------------------------------------- | | Reorganizes existing server-rendered nodes into the viewport shell. | Existing nodes are MOVED, not cloned/replaced, so the tick-control | event listeners installed above remain intact. | */ (() => { 'use strict'; if ( document.getElementById( 'wayfarer-play-shell' ) ) { return; } function exactHeading( level, text ) { const headings = document.querySelectorAll( level ); for (const heading of headings) { if ( heading.textContent .replace(/\s+/g, ' ') .trim() === text ) { return heading; } } return null; } function moveRange( start, stopExclusive, destination ) { if ( !start || !destination ) { return; } let node = start; while ( node && node !== stopExclusive ) { const next = node.nextSibling; destination.appendChild( node ); node = next; } } const body = document.body; /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * PHP decides whether Author Mode is permitted. JavaScript only shapes the * interface around that server-side decision. */ const canUseAuthorMode = body.dataset.wayfarerAuthorMode === '1'; body.classList.add( 'wayfarer-play-page' ); const shell = document.createElement( 'div' ); shell.id = 'wayfarer-play-shell'; shell.className = 'wayfarer-play-shell'; const header = document.createElement( 'header' ); header.className = 'wayfarer-play-shell-header'; const title = document.createElement( 'div' ); title.className = 'wayfarer-play-title'; const actions = document.createElement( 'div' ); actions.className = 'wayfarer-shell-actions'; const toggleAuthor = document.createElement( 'button' ); toggleAuthor.type = 'button'; toggleAuthor.className = 'wayfarer-shell-button'; if (canUseAuthorMode) { actions.appendChild( toggleAuthor ); } header.appendChild( title ); if (canUseAuthorMode) { header.appendChild( actions ); } const main = document.createElement( 'main' ); main.className = 'wayfarer-play-main'; const playerPane = document.createElement( 'section' ); playerPane.className = 'wayfarer-player-pane'; const authorPane = document.createElement( 'aside' ); authorPane.className = 'wayfarer-author-pane'; const authorHeader = document.createElement( 'div' ); authorHeader.className = 'wayfarer-author-header'; const authorToolbar = document.createElement( 'div' ); authorToolbar.className = 'wayfarer-author-toolbar'; const tabBar = document.createElement( 'div' ); tabBar.className = 'wayfarer-author-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const authorContent = document.createElement( 'div' ); authorContent.className = 'wayfarer-author-content'; authorPane.appendChild( authorHeader ); authorPane.appendChild( authorToolbar ); authorPane.appendChild( tabBar ); authorPane.appendChild( authorContent ); main.appendChild( playerPane ); if (canUseAuthorMode) { main.appendChild( authorPane ); } else { /* * Keep the player workspace full-width when Author Mode is unavailable. * No Author pane was rendered by PHP and none is attached here. */ shell.classList.add( 'author-collapsed' ); } shell.appendChild( header ); shell.appendChild( main ); body.insertBefore( shell, body.firstChild ); // ------------------------------------------------------------ // Top title/player identity // ------------------------------------------------------------ const pageTitle = Array.from( body.querySelectorAll( ':scope > h1' ) )[0]; if (pageTitle) { const playerIdentity = pageTitle.nextElementSibling; title.appendChild( pageTitle ); if ( playerIdentity && playerIdentity.tagName === 'P' ) { title.appendChild( playerIdentity ); } } // ------------------------------------------------------------ // Player content // ------------------------------------------------------------ /* * WAYFARER_PLAYER_START_SCREEN_V36 * * Before runtime state exists, the start screen owns the full player * workspace and Author Mode is intentionally hidden. */ const startScreen = document.querySelector( '[data-wayfarer-start-screen]' ); if (startScreen) { shell.classList.add( 'is-starting' ); playerPane.appendChild( startScreen ); } const scene = document.getElementById( 'wayfarer-player-scene' ); const gameStateHeading = exactHeading( 'h2', 'Game State' ) || exactHeading( 'h2', 'Game Not Initialized' ); if (gameStateHeading) { const meta = document.createElement( 'div' ); meta.className = 'wayfarer-player-meta'; /* * Move from Game State through the node immediately before the * player scene. This includes initialization messaging/forms but * stops before the actual scene. */ moveRange( gameStateHeading, scene, meta ); playerPane.appendChild( meta ); } if (scene) { playerPane.appendChild( scene ); } /* * WAYFARER_ACTION_SCREEN_V39 * * Action Mode is player-facing and exists independently of Author Mode. */ const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); if (actionScreen) { playerPane.appendChild( actionScreen ); } /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); if (presentationScreen) { playerPane.appendChild( presentationScreen ); } const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); if (travelControls) { playerPane.appendChild( travelControls ); } /* WAYFARER_PLAYER_HUD_V64 */ const persistentHud = document.querySelector( '[data-wayfarer-hud]' ); if (persistentHud) { playerPane.appendChild( persistentHud ); } // ------------------------------------------------------------ // Author header // ------------------------------------------------------------ const authorHeading = exactHeading( 'h2', 'Author Mode' ); if (authorHeading) { const builderLinkBlock = authorHeading.nextElementSibling; authorHeader.appendChild( authorHeading ); if ( builderLinkBlock && builderLinkBlock.tagName === 'P' ) { authorHeader.appendChild( builderLinkBlock ); } } // ------------------------------------------------------------ // Runtime controls // ------------------------------------------------------------ const tickPanel = document.getElementById( 'wayfarer-tick-test' ); if (tickPanel) { authorToolbar.appendChild( tickPanel ); } // ------------------------------------------------------------ // Author tabs // ------------------------------------------------------------ /* WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 */ const panelDefinitions = [ { key: 'state', label: 'State' }, { key: 'debug', label: 'Debug' } ]; function activateTab(key) { document .querySelectorAll( '.wayfarer-author-tab' ) .forEach( function (button) { const active = button.dataset .wayfarerAuthorTab === key; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); authorContent .querySelectorAll( '.wayfarer-author-tab-panel' ) .forEach( function (panel) { panel.classList.toggle( 'is-active', panel.dataset .wayfarerAuthorPanel === key ); } ); try { window.localStorage.setItem( 'wayfarerAuthorTab', key ); } catch (error) { // Storage is optional. } } for (const definition of panelDefinitions) { const panel = document.querySelector( '[data-wayfarer-author-panel="' + definition.key + '"]' ); if (!panel) { continue; } authorContent.appendChild( panel ); const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-author-tab'; button.dataset.wayfarerAuthorTab = definition.key; button.setAttribute( 'role', 'tab' ); button.textContent = definition.label; button.addEventListener( 'click', function () { activateTab( definition.key ); } ); tabBar.appendChild( button ); } let initialTab = 'state'; try { let savedTab = window.localStorage.getItem( 'wayfarerAuthorTab' ); if ( savedTab === 'region' ) { savedTab = 'state'; window.localStorage.setItem( 'wayfarerAuthorTab', 'state' ); } if ( savedTab && authorContent.querySelector( '[data-wayfarer-author-panel="' + savedTab + '"]' ) ) { initialTab = savedTab; } } catch (error) { // Storage is optional. } activateTab( initialTab ); // ------------------------------------------------------------ // Hide / Show Author Mode // WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 // ------------------------------------------------------------ if (canUseAuthorMode) { function renderAuthorToggle() { const collapsed = shell.classList.contains( 'author-collapsed' ); toggleAuthor.textContent = collapsed ? 'Show Author' : 'Hide Author'; toggleAuthor.setAttribute( 'aria-expanded', collapsed ? 'false' : 'true' ); } toggleAuthor.addEventListener( 'click', function () { shell.classList.toggle( 'author-collapsed' ); try { window.localStorage.setItem( 'wayfarerAuthorCollapsed', shell.classList.contains( 'author-collapsed' ) ? '1' : '0' ); } catch (error) { // Storage is optional. } renderAuthorToggle(); } ); try { if ( window.localStorage.getItem( 'wayfarerAuthorCollapsed' ) === '1' ) { shell.classList.add( 'author-collapsed' ); } } catch (error) { // Storage is optional. } renderAuthorToggle(); } else { /* * Player role is not merely a hidden Author interface. Keep the shell * permanently in player-only layout. */ shell.classList.add( 'author-collapsed' ); } // ------------------------------------------------------------ // Remove otherwise-empty top-level HRs left by the old document flow. // ------------------------------------------------------------ body .querySelectorAll( ':scope > hr' ) .forEach( function (rule) { rule.remove(); } ); })(); /* |-------------------------------------------------------------------------- | ENDING RESTART | WAYFARER_ENDING_RESTART_V63 |-------------------------------------------------------------------------- */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; const restartButton = document.querySelector( '[data-wayfarer-restart-game]' ); if ( !config || !restartButton ) { return; } let requestInFlight = false; restartButton.addEventListener( 'click', async function () { if (requestInFlight) { return; } if ( !window.confirm( 'Start this game over? Your current playthrough progress will be erased.' ) ) { return; } requestInFlight = true; restartButton.disabled = true; const originalText = restartButton.textContent; restartButton.textContent = 'Starting over…'; try { const response = await fetch( config.restartEndpoint || 'api/restart.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Restart endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not restart the game.' ); } document.body.classList.add( 'wayfarer-presentation-transitioning' ); window.location.href = 'index.php'; } catch (error) { requestInFlight = false; restartButton.disabled = false; restartButton.textContent = originalText; window.alert( error instanceof Error ? error.message : 'Could not restart the game.' ); } } ); })(); ================================================================================ FILE: _update_backups/v71-20260928-133621/play/includes/action-screen.php ================================================================================ 0) { $actionRegionStmt = $pdo->prepare( " SELECT id, name, navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $actionRegionStmt->execute([ $actionRegionId, (int)$gameId, ]); $actionRegion = $actionRegionStmt->fetch( PDO::FETCH_ASSOC ); if ($actionRegion) { $actionRegionNavigationType = (string)( $actionRegion['navigation_type'] ?? '' ); $actionContextName = trim( (string)( $actionRegion['name'] ?? '' ) ) ?: 'Actions'; } } $actionLocation = wayfarerRuntimeFindCurrentLocation( $pdo, (int)$gameId, $state ); if ($actionLocation) { $locationName = trim( (string)( $actionLocation['name'] ?? '' ) ); if ($locationName !== '') { $actionContextName = $locationName; } } $actionCanTravel = strtolower( trim( $actionRegionNavigationType ) ) === 'linear'; /* WAYFARER_STORYLET_ACTION_SCREEN_V59 */ $actionStorylets = wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$gameId, $state ); /* * WAYFARER_ENTITY_PRESENTATION_V49 * * The V48 card switcher remains generic. V49 supplies the Inventory / Status * view models from canonical runtime Entity state. */ $actionEntityPresentation = wayfarerRuntimeBuildEntityPresentation( $pdo, (int)$gameId, $state, is_array($gameConfig ?? null) ? $gameConfig : [] ); $actionInventory = $actionEntityPresentation['inventory'] ?? []; $actionParty = $actionEntityPresentation['party'] ?? []; /* WAYFARER_PLAYER_TRAITS_STATUS_V52 */ $actionPlayerPresentation = wayfarerRuntimeBuildPlayerPresentation( is_array($gameConfig ?? null) ? $gameConfig : [], $state ); $actionPlayers = $actionPlayerPresentation['players'] ?? []; $actionGameStatusTraits = $actionPlayerPresentation['game_status_traits'] ?? []; /* WAYFARER_TRAVEL_TIME_HUD_V61 */ $actionTravelStatus = wayfarerRuntimeBuildTravelStatus( $pdo, (int)$gameId, $state ); ?>

>

What would you like to do?

No actions are currently available.

================================================================================ FILE: _update_backups/v71-20260928-133621/play/includes/effects.php ================================================================================ true, 'value' => wayfarerNormalizeRuntimeNumber( (float)$effectValue ), 'error' => '', ]; } return [ 'applied' => true, 'value' => $effectValue, 'error' => '', ]; } if ( $operation !== 'add' && $operation !== 'subtract' ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Unsupported operation: ' . $operation, ]; } if ( !is_numeric($currentValue) || !is_numeric($effectValue) ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Add/subtract requires numeric values.', ]; } $current = (float)$currentValue; $amount = (float)$effectValue; $result = $operation === 'add' ? $current + $amount : $current - $amount; return [ 'applied' => true, 'value' => wayfarerNormalizeRuntimeNumber( $result ), 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Tick Effect branch runtime |-------------------------------------------------------------------------- | | A Tick Effect owns one target and an ordered list of result branches. | Conditions are evaluated against the CURRENT runtime state. Because Tick | Effects execute sequentially, a later Tick Effect can see state changes made | by an earlier Tick Effect during the same tick. | */ function wayfarerRuntimeResolveConditionTarget( array $runtimeState, int $currentRegionId, string $scope, string $targetType, string $targetId ): array { $scope = strtolower( trim( $scope ) ); $targetType = strtolower( trim( $targetType ) ); $targetId = trim( $targetId ); // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $targetType === 'trait' && $targetId !== '' ) { $traits = $runtimeState['game']['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // SPECIFIC REGION TRAIT // WAYFARER_SPECIFIC_REGION_CONDITION_SCOPE_V46 // ------------------------------------------------------------ // // Game-level condition editors store a Region-owned Trait as // "region:". This lets a global rule inspect that Region whether // or not the player is currently there. // if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^region:([1-9][0-9]*)$/', $scope, $regionScopeMatch ) === 1 ) { $regionKey = (string)( (int)$regionScopeMatch[1] ); $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $targetType === 'trait' && $targetId !== '' && $currentRegionId > 0 ) { $regionKey = (string)$currentRegionId; $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // PLAYER / LOCATION / ENTITY TRAITS // WAYFARER_RUNTIME_UNIVERSAL_TARGET_RESOLVER_V56 // ------------------------------------------------------------ if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^player:([0-9]+)$/', $scope, $playerScopeMatch ) === 1 ) { $playerKey = (string)((int)$playerScopeMatch[1]); $traits = $runtimeState['players'][$playerKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^location:([1-9][0-9]*)$/', $scope, $locationScopeMatch ) === 1 ) { $locationKey = (string)((int)$locationScopeMatch[1]); $traits = $runtimeState['locations'][$locationKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityScopeMatch ) === 1 ) { $entityKey = (string)((int)$entityScopeMatch[1]); $traits = $runtimeState['entities'][$entityKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $targetType === 'position' && $targetId === 'position' ) { if ( isset( $runtimeState['navigation'] ) && is_array( $runtimeState['navigation'] ) && array_key_exists( 'position', $runtimeState['navigation'] ) ) { return [ 'found' => true, 'value' => $runtimeState['navigation']['position'], ]; } return [ 'found' => false, 'value' => null, ]; } return [ 'found' => false, 'value' => null, ]; } function wayfarerRuntimeTickEffectBranches( array $effect ): array { /* * v28+ canonical structure. * * If the branches key exists, it is authoritative. An empty branches * array therefore means "this effect has no executable results" rather * than silently falling back to legacy compatibility fields. */ if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * Legacy pre-v28 structure. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'conditions', $effect ) ) { $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $effect['operation'] ?? '', 'value_mode' => $effect['value_mode'] ?? 'fixed', ]; if ( strtolower( trim( (string)$branch['value_mode'] ) ) === 'random' ) { $branch['value_min'] = $effect['value_min'] ?? 0; $branch['value_max'] = $effect['value_max'] ?? 0; } else { $branch['value'] = $effect['value'] ?? 0; } return [ $branch ]; } return []; } function wayfarerRuntimeSelectTickEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeTickEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Tick Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; /* * Validate before evaluating. Invalid authored data fails closed and * does NOT fall through to OTHERWISE, because doing so could silently * produce the wrong gameplay result. */ $validation = wayfarerConditionValidateNode( $conditions ); if (!$validation['valid']) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Tick Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Runtime Trait bounds |-------------------------------------------------------------------------- | | WAYFARER_RUNTIME_TRAIT_BOUNDS_V30 | | Authored numeric Trait min/max values are runtime invariants. | State is normalized before condition evaluation and every subsequent | mutation is clamped again before it is written. | */ function wayfarerRuntimeDecodeTraitConfig( $rawConfig ): array { if (is_array($rawConfig)) { return $rawConfig; } if ( $rawConfig === null || $rawConfig === '' ) { return []; } $decoded = json_decode( (string)$rawConfig, true ); return is_array($decoded) ? $decoded : []; } function wayfarerRuntimeIndexTraitDefinitions( array $traits ): array { $definitions = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $definitions[$traitId] = $trait; } return $definitions; } function wayfarerRuntimeLoadTickTraitDefinitions( PDO $pdo, int $gameId, array $regionConfig ): array { $gameConfig = []; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawGameConfig = $stmt->fetchColumn(); if ($rawGameConfig !== false) { $gameConfig = wayfarerRuntimeDecodeTraitConfig( $rawGameConfig ); } } /* WAYFARER_RUNTIME_UNIVERSAL_DEFINITIONS_V56 */ $definitions = [ 'game' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['traits'] ?? null ) ? $gameConfig['traits'] : [] ), 'player' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['player_traits'] ?? null ) ? $gameConfig['player_traits'] : [] ), 'region' => wayfarerRuntimeIndexTraitDefinitions( is_array( $regionConfig['traits'] ?? null ) ? $regionConfig['traits'] : [] ), 'regions' => [], 'locations' => [], 'entities' => [], ]; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $definitions['regions'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } $stmt = $pdo->prepare( " SELECT l.id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $definitions['locations'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } $stmt = $pdo->prepare( " SELECT id, entity_type, config FROM wf_entities WHERE game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); if ( function_exists( 'wayfarerEntityActiveTraitDefinitions' ) ) { $definitions['entities'][$id] = wayfarerEntityActiveTraitDefinitions( $config, (string)( $row['entity_type'] ?? '' ) ); } else { $definitions['entities'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } } } return $definitions; } function wayfarerRuntimeClampValueToTraitDefinition( $value, ?array $definition ): array { $result = [ 'valid' => true, 'value' => $value, 'clamped' => false, 'min' => null, 'max' => null, 'error' => '', ]; /* * Bounds currently belong only to authored Number Traits. */ if ( !$definition || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' ) { return $result; } if (!is_numeric($value)) { $result['valid'] = false; $result['error'] = 'Numeric Trait runtime state must be numeric.'; return $result; } $min = isset($definition['min']) && $definition['min'] !== '' && is_numeric($definition['min']) ? (float)$definition['min'] : null; $max = isset($definition['max']) && $definition['max'] !== '' && is_numeric($definition['max']) ? (float)$definition['max'] : null; $numeric = (float)$value; $bounded = $numeric; if ( $min !== null && $bounded < $min ) { $bounded = $min; } if ( $max !== null && $bounded > $max ) { $bounded = $max; } $result['value'] = wayfarerNormalizeRuntimeNumber( $bounded ); $result['clamped'] = $bounded !== $numeric; $result['min'] = $min === null ? null : wayfarerNormalizeRuntimeNumber( $min ); $result['max'] = $max === null ? null : wayfarerNormalizeRuntimeNumber( $max ); return $result; } function wayfarerRuntimeApplyBoundedTraitOperation( $storedBefore, string $operation, $effectValue, ?array $definition ): array { /* * Repair an already-illegal persisted value BEFORE applying the new * operation. Example: 120 with max 100 and subtract 10 becomes * 100 - 10 = 90, not 120 - 10 = 110 -> 100. */ $beforeBound = wayfarerRuntimeClampValueToTraitDefinition( $storedBefore, $definition ); if (!$beforeBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $storedBefore, 'requested_after' => $storedBefore, 'after' => $storedBefore, 'clamped' => false, 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $beforeBound['error'], ]; } $normalizedBefore = $beforeBound['value']; $operationResult = wayfarerApplyRuntimeOperation( $normalizedBefore, $operation, $effectValue ); if (!$operationResult['applied']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $normalizedBefore, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $operationResult['error'], ]; } $requestedAfter = $operationResult['value']; $afterBound = wayfarerRuntimeClampValueToTraitDefinition( $requestedAfter, $definition ); if (!$afterBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $afterBound['error'], ]; } return [ 'applied' => true, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $afterBound['value'], 'clamped' => $beforeBound['clamped'] || $afterBound['clamped'], 'min' => $afterBound['min'], 'max' => $afterBound['max'], 'error' => '', ]; } function wayfarerRuntimeClampApplicableTraitState( array &$runtimeState, array $traitDefinitions, int $currentRegionId ): array { $repairs = []; // ------------------------------------------------------------ // GAME TRAITS // ------------------------------------------------------------ $gameTraits = $runtimeState['game']['traits'] ?? null; if (is_array($gameTraits)) { foreach ( $traitDefinitions['game'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $gameTraits ) ) { continue; } $before = $runtimeState['game']['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['game']['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } // ------------------------------------------------------------ // CURRENT REGION TRAITS // ------------------------------------------------------------ $regionKey = (string)$currentRegionId; $regionTraits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if (is_array($regionTraits)) { foreach ( $traitDefinitions['region'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $regionTraits ) ) { continue; } $before = $runtimeState['regions'][$regionKey]['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } return $repairs; } function wayfarerApplyRuntimeTickEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectTickEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection['branch_index'], 'applied' => false, 'error' => $selection['error'], ]; } /* * No conditional branch matched and there was no OTHERWISE. * This is a valid "do nothing this tick" result. */ if (!$selection['matched']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection['branch_index']; $operation = strtolower( trim( (string)( $branch['operation'] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; /* * Explicit No Change: * the branch DID match, so this Tick Effect stops here, but state does * not mutate. */ if ($operation === 'none') { $current = wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $type, $targetId ); return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'amount' => null, 'before' => $current['found'] ? $current['value'] : null, 'after' => $current['found'] ? $current['value'] : null, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'applied' => false, 'no_change' => true, 'error' => '', ]; } /* WAYFARER_TICK_EFFECT_RANDOM_RUNTIME_V58 */ $resolvedValue = wayfarerRuntimeRollEffectValue( $branch ); if (!$resolvedValue['valid']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'applied' => false, 'error' => $resolvedValue['error'], ]; } /* * Apply ONLY the selected result branch. The legacy effect-level * operation/value fields are deliberately ignored when branches exist. */ $resolvedEffect = $effect; $resolvedEffect['operation'] = $operation; $resolvedEffect['value'] = $resolvedValue['value']; $report = wayfarerApplyRuntimeEffect( $runtimeState, $resolvedEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; $report['value_mode'] = $resolvedValue['mode']; $report['rolled_value'] = $resolvedValue['value']; if ($resolvedValue['mode'] === 'random') { $report['random_min'] = $resolvedValue['min']; $report['random_max'] = $resolvedValue['max']; } return $report; } function wayfarerApplyRuntimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); $operation = strtolower( trim( (string)( $effect['operation'] ?? '' ) ) ); $effectValue = $effect['value'] ?? 0; // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $type === 'position' && $targetId === 'position' ) { if ( !isset($runtimeState['navigation']) || !is_array($runtimeState['navigation']) ) { $runtimeState['navigation'] = []; } $before = $runtimeState['navigation']['position'] ?? 0; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); $after = $before; if ( $result['applied'] && !is_numeric($result['value']) ) { $result['applied'] = false; $result['error'] = 'Navigation position must be numeric.'; } if ($result['applied']) { /* * Global navigation rule: * position may never be less than zero. */ $after = max( 0, $result['value'] ); $runtimeState['navigation']['position'] = $after; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $after, 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['game']) || !is_array($runtimeState['game']) ) { $runtimeState['game'] = []; } if ( !isset($runtimeState['game']['traits']) || !is_array($runtimeState['game']['traits']) ) { $runtimeState['game']['traits'] = []; } $before = $runtimeState['game']['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['game'][$targetId] ) && is_array( $traitDefinitions['game'][$targetId] ) ? $traitDefinitions['game'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); /* * Persist the normalized value even when a malformed operation fails * after an existing illegal value was repaired. */ if ( $result['normalized_before'] !== $before ) { $runtimeState['game']['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['game']['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // ITEM ENTITY QUANTITY // WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 // ------------------------------------------------------------ if ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityScopeMatch ) === 1 && ( (string)( $effect['target_system_key'] ?? '' ) === 'quantity' || $targetId === 'sys_item_quantity' ) ) { $entityKey = (string)( (int)$entityScopeMatch[1] ); if ( !isset( $runtimeState['entities'][$entityKey] ) || !is_array( $runtimeState['entities'][$entityKey] ) || !isset( $runtimeState['entities'][$entityKey]['traits'] ) || !is_array( $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Entity runtime state was not initialized.', ]; } if ( !array_key_exists( $targetId, $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Quantity runtime state was not found.', ]; } $before = $runtimeState['entities'][$entityKey]['traits'][$targetId]; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); if ( !$result['applied'] || !is_numeric( $result['value'] ?? null ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $before, 'applied' => false, 'error' => $result['error'] !== '' ? $result['error'] : 'Item Quantity must remain numeric.', ]; } $requestedAfter = wayfarerNormalizeRuntimeNumber( (float)$result['value'] ); /* * Quantity is globally non-negative. */ $after = wayfarerNormalizeRuntimeNumber( max( 0.0, (float)$requestedAfter ) ); $runtimeState['entities'][$entityKey]['traits'][$targetId] = $after; /* * Discovery is persistent. Receiving any positive starting quantity * discovers the Item; setting it back to zero never undiscoveres it. */ if ( (float)$after > 0 ) { $runtimeState['entities'][$entityKey]['discovered'] = true; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_system_key' => 'quantity', 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'requested_after' => $requestedAfter, 'after' => $after, 'clamped' => (float)$after !== (float)$requestedAfter, 'min' => 0, 'max' => null, 'applied' => true, 'error' => '', ]; } // ------------------------------------------------------------ // SPECIFIC PLAYER / REGION / LOCATION / ENTITY TRAITS // WAYFARER_RUNTIME_UNIVERSAL_EFFECT_TARGETS_V56 // ------------------------------------------------------------ $stateBucket = null; $stateKey = null; $definition = null; if ( $type === 'trait' && $targetId !== '' && preg_match( '/^player:([0-9]+)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'players'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['player'][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^region:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'regions'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['regions'][$stateKey][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^location:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'locations'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['locations'][$stateKey][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 && $targetId !== 'sys_item_quantity' ) { $stateBucket = 'entities'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['entities'][$stateKey][$targetId] ?? null; } if ( $stateBucket !== null && $stateKey !== null ) { if ( !isset($runtimeState[$stateBucket][$stateKey]) || !is_array($runtimeState[$stateBucket][$stateKey]) || !isset($runtimeState[$stateBucket][$stateKey]['traits']) || !is_array($runtimeState[$stateBucket][$stateKey]['traits']) || !array_key_exists( $targetId, $runtimeState[$stateBucket][$stateKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Runtime Trait target was not found.', ]; } $before = $runtimeState[$stateBucket][$stateKey]['traits'][$targetId]; /* * Party Membership is mutable runtime state, but still a binary * Wayfarer system property. The 0/1 rule is enforced internally and is * never exposed as a separate Target/max metadata value. */ if ( $stateBucket === 'entities' && $targetId === 'sys_character_party_member' ) { $operationResult = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); if ( !$operationResult['applied'] || !is_numeric( $operationResult['value'] ?? null ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $before, 'applied' => false, 'error' => $operationResult['error'] !== '' ? $operationResult['error'] : 'Party Membership requires a numeric 0/1 result.', ]; } $after = (float)$operationResult['value'] > 0 ? 1 : 0; $runtimeState[$stateBucket][$stateKey]['traits'][$targetId] = $after; return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_system_key' => 'party_member', 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $after, 'applied' => true, 'error' => '', ]; } $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, is_array($definition) ? $definition : null ); if ($result['applied']) { $runtimeState[$stateBucket][$stateKey]['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // PLAYER TRAIT // WAYFARER_STARTING_CHOICE_PLAYER_TARGETS_V53 // ------------------------------------------------------------ if ( $scope === 'player' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['players']) || !is_array($runtimeState['players']) ) { $runtimeState['players'] = []; } $selector = strtolower( trim( (string)( $effect['target_selector'] ?? '' ) ) ); $targetKeys = []; if ($selector === 'all') { foreach ( $runtimeState['players'] as $playerKey => $playerState ) { if (is_array($playerState)) { $targetKeys[] = (string)$playerKey; } } } elseif ( $selector === 'slot' && array_key_exists( 'target_slot', $effect ) ) { $slot = max( 0, (int)$effect['target_slot'] ); $targetKeys[] = (string)$slot; } else { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect requires an All Players or Player Slot target.', ]; } if (empty($targetKeys)) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect did not resolve any Player Characters.', ]; } $definition = isset( $traitDefinitions['player'][$targetId] ) && is_array( $traitDefinitions['player'][$targetId] ) ? $traitDefinitions['player'][$targetId] : null; $results = []; $firstError = ''; foreach ($targetKeys as $playerKey) { if ( !isset( $runtimeState['players'][$playerKey] ) || !is_array( $runtimeState['players'][$playerKey] ) ) { $firstError = 'Player Character slot ' . ((int)$playerKey + 1) . ' does not exist in runtime state.'; $results[$playerKey] = [ 'applied' => false, 'error' => $firstError, ]; break; } if ( !isset( $runtimeState['players'][$playerKey]['traits'] ) || !is_array( $runtimeState['players'][$playerKey]['traits'] ) ) { $runtimeState['players'][$playerKey]['traits'] = []; } $before = $runtimeState['players'][$playerKey]['traits'][$targetId] ?? 0; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['after']; } $results[$playerKey] = [ 'slot' => (int)$playerKey, 'name' => (string)( $runtimeState['players'][$playerKey]['name'] ?? ( 'Player ' . ((int)$playerKey + 1) ) ), 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; if ( !$result['applied'] || $result['error'] !== '' ) { $firstError = $result['error'] !== '' ? $result['error'] : 'Player Trait effect could not be applied.'; break; } } $allApplied = $firstError === ''; return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_selector' => $selector, 'target_slot' => $selector === 'slot' ? (int)( $effect['target_slot'] ?? 0 ) : null, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'results' => $results, 'applied' => $allApplied, 'error' => $firstError, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $type === 'trait' && $targetId !== '' ) { $regionKey = (string)$currentRegionId; if ( !isset($runtimeState['regions']) || !is_array($runtimeState['regions']) ) { $runtimeState['regions'] = []; } if ( !isset($runtimeState['regions'][$regionKey]) || !is_array($runtimeState['regions'][$regionKey]) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset($runtimeState['regions'][$regionKey]['traits']) || !is_array($runtimeState['regions'][$regionKey]['traits']) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } $before = $runtimeState['regions'][$regionKey]['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['region'][$targetId] ) && is_array( $traitDefinitions['region'][$targetId] ) ? $traitDefinitions['region'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Unsupported runtime target.', ]; } /* |-------------------------------------------------------------------------- | Game Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_GAME_TICK_EFFECT_RUNTIME_V33 | | Game Tick Effects execute once on every successful runtime tick before | the active Region's Tick Effects. This creates a deterministic layering: | | Game rules -> Region rules | */ function wayfarerApplyGameTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { if ($gameId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active game is available for Game Tick Effects.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { $reports = [ [ 'applied' => false, 'error' => 'Game ' . $gameId . ' was not found.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $gameConfig = []; if ( $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, [] ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $gameEffects = $gameConfig['tick_effects'] ?? []; if (!is_array($gameEffects)) { $gameEffects = []; } $reports = []; foreach ($gameEffects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Game Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; $runtimeState['runtime']['last_game_trait_bound_repairs'] = $boundRepairs; return $reports; } /* |-------------------------------------------------------------------------- | Generalized one-time Effect runtime | WAYFARER_GENERALIZED_EFFECT_RUNTIME_V43 |-------------------------------------------------------------------------- | | Conditions use the same shared evaluator as Tick Effect branches. | Random values are resolved exactly once, immediately before the selected | result mutates runtime state. | */ function wayfarerRuntimeEffectBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * v42 compatibility: one Effect was one unconditional operation/value. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $branch = [ 'conditions' => null, 'operation' => $effect['operation'] ?? 'add', 'value_mode' => $effect['value_mode'] ?? 'fixed', ]; if ( strtolower( trim( (string)( $branch[ 'value_mode' ] ) ) ) === 'random' ) { $branch['value_min'] = $effect['value_min'] ?? 0; $branch['value_max'] = $effect['value_max'] ?? 0; } else { $branch['value'] = $effect['value'] ?? 0; } return [ $branch ]; } return []; } function wayfarerRuntimeSelectEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; $validation = wayfarerConditionValidateNode( $conditions ); if ( !$validation['valid'] ) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } function wayfarerRuntimeRollEffectValue( array $branch ): array { $mode = strtolower( trim( (string)( $branch[ 'value_mode' ] ?? 'fixed' ) ) ); if ($mode !== 'random') { $value = $branch['value'] ?? null; /* WAYFARER_FIXED_SCALAR_EFFECT_RUNTIME_V58 */ return [ 'valid' => true, 'mode' => 'fixed', 'value' => is_numeric($value) ? wayfarerNormalizeRuntimeNumber( (float)$value ) : $value, 'min' => null, 'max' => null, 'error' => '', ]; } $minimum = $branch['value_min'] ?? null; $maximum = $branch['value_max'] ?? null; if ( !is_numeric($minimum) || !is_numeric($maximum) ) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => $minimum, 'max' => $maximum, 'error' => 'Random Effect requires numeric Min and Max values.', ]; } $min = (float)$minimum; $max = (float)$maximum; if ($min > $max) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => 'Random Effect Min cannot be greater than Max.', ]; } if ( floor($min) === $min && floor($max) === $max ) { $rolled = random_int( (int)$min, (int)$max ); } else { /* * Continuous decimal range. Six decimal places of entropy are more * than sufficient for authored simulation values and preserve generic * numeric Min/Max support without requiring authored precision metadata. */ $fraction = random_int( 0, 1000000 ) / 1000000; $rolled = $min + (($max - $min) * $fraction); } return [ 'valid' => true, 'mode' => 'random', 'value' => wayfarerNormalizeRuntimeNumber( (float)$rolled ), 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => '', ]; } function wayfarerApplyRuntimeOneTimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect[ 'target_scope' ] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect[ 'target_type' ] ?? '' ) ) ); $targetId = trim( (string)( $effect[ 'target_id' ] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection[ 'branch_index' ], 'applied' => false, 'error' => $selection[ 'error' ], ]; } if ( !$selection['matched'] ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection[ 'branch_index' ]; $operation = strtolower( trim( (string)( $branch[ 'operation' ] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; if ($operation === 'none') { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => null, 'rolled_value' => null, 'applied' => false, 'error' => '', ]; } $resolved = wayfarerRuntimeRollEffectValue( $branch ); if (!$resolved['valid']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => $resolved[ 'mode' ], 'rolled_value' => null, 'random_min' => $resolved[ 'min' ], 'random_max' => $resolved[ 'max' ], 'applied' => false, 'error' => $resolved[ 'error' ], ]; } $concreteEffect = [ 'target_scope' => $scope, 'target_type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'value' => $resolved[ 'value' ], ]; $report = wayfarerApplyRuntimeEffect( $runtimeState, $concreteEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; $report['value_mode'] = $resolved[ 'mode' ]; $report['rolled_value'] = $resolved[ 'value' ]; if ( $resolved['mode'] === 'random' ) { $report['random_min'] = $resolved[ 'min' ]; $report['random_max'] = $resolved[ 'max' ]; } return $report; } /* |-------------------------------------------------------------------------- | One-time Effect player feedback | WAYFARER_ONE_TIME_EFFECT_FEEDBACK_V70 |-------------------------------------------------------------------------- | | Change events are derived from the Effect report AFTER runtime mutation. | Therefore Random rolls and min/max/non-negative clamps are already reflected | in before/after, and the player sees the quantity that truly changed. | | This layer is intentionally called only by narrative one-time Effect paths. | Tick Effects remain silent. | */ function wayfarerRuntimeFeedbackFindTrait( array $config, string $targetId, string $collection = 'traits' ): ?array { $traits = isset($config[$collection]) && is_array($config[$collection]) ? $config[$collection] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } $id = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($id === $targetId) { return $trait; } } return null; } function wayfarerRuntimeFeedbackTraitIsPlayerFacing(array $trait): bool { $feedback = strtolower( trim( (string)( $trait['feedback'] ?? '' ) ) ); if ($feedback === 'hide') { return false; } if ($feedback === 'show') { return true; } $display = isset($trait['display']) && is_array($trait['display']) ? $trait['display'] : []; $surfaces = isset($display['surfaces']) && is_array($display['surfaces']) ? $display['surfaces'] : []; return !empty($surfaces); } function wayfarerRuntimeFeedbackTraitMetadata( array $trait, string $fallbackLabel, string $ownerLabel = '' ): ?array { if (!wayfarerRuntimeFeedbackTraitIsPlayerFacing($trait)) { return null; } $label = trim( (string)( $trait['name'] ?? $fallbackLabel ) ); if ($label === '') { $label = $fallbackLabel; } $type = strtolower( trim( (string)( $trait['type'] ?? 'number' ) ) ); if ($type === 'binary') { $type = 'boolean'; } return [ 'kind' => 'trait', 'label' => $label, 'owner_label' => $ownerLabel, 'data_type' => $type, ]; } function wayfarerRuntimeFeedbackTargetMetadata( PDO $pdo, int $gameId, array $runtimeState, array $report ): ?array { $scope = strtolower( trim( (string)( $report['scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $report['type'] ?? '' ) ) ); $targetId = trim( (string)( $report['target_id'] ?? '' ) ); if ( $scope === '' || $targetId === '' || $type !== 'trait' ) { return null; } /* Navigation is deliberately silent even for one-time Effects. */ if ($scope === 'navigation') { return null; } /* -------------------------------------------------------------- * Entity state: Quantity and Party Membership are intrinsically visible. * ----------------------------------------------------------- */ if ( preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityMatch ) === 1 ) { $entityId = (int)$entityMatch[1]; $stmt = $pdo->prepare( " SELECT name, entity_type, config FROM wf_entities WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $entityId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $entityName = trim( (string)( $row['name'] ?? ('Entity ' . $entityId) ) ); if ($entityName === '') { $entityName = 'Entity ' . $entityId; } $systemKey = strtolower( trim( (string)( $report['target_system_key'] ?? '' ) ) ); if ( $targetId === 'sys_item_quantity' || $systemKey === 'quantity' ) { return [ 'kind' => 'item_quantity', 'label' => $entityName, 'owner_label' => $entityName, 'data_type' => 'number', ]; } if ( $targetId === 'sys_character_party_member' || $systemKey === 'party_member' ) { return [ 'kind' => 'party_membership', 'label' => $entityName, 'owner_label' => $entityName, 'data_type' => 'boolean', ]; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, $entityName ) : null; } /* -------------------------------------------------------------- * Game Trait. * ----------------------------------------------------------- */ if ($scope === 'game') { $stmt = $pdo->prepare( 'SELECT config FROM wf_games WHERE id = ? LIMIT 1' ); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); if ($raw === false) { return null; } $config = wayfarerRuntimeDecodeTraitConfig($raw); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, 'Game' ) : null; } /* -------------------------------------------------------------- * Specific Region Trait, plus legacy current-Region compatibility. * ----------------------------------------------------------- */ $regionId = 0; if ( preg_match( '/^region:([1-9][0-9]*)$/', $scope, $regionMatch ) === 1 ) { $regionId = (int)$regionMatch[1]; } elseif ($scope === 'region') { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); } if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, trim((string)($row['name'] ?? 'Region')) ) : null; } /* -------------------------------------------------------------- * Location Trait. * ----------------------------------------------------------- */ if ( preg_match( '/^location:([1-9][0-9]*)$/', $scope, $locationMatch ) === 1 ) { $locationId = (int)$locationMatch[1]; $stmt = $pdo->prepare( " SELECT l.name, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE l.id = ? AND r.game_id = ? LIMIT 1 " ); $stmt->execute([ $locationId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, trim((string)($row['name'] ?? 'Location')) ) : null; } /* -------------------------------------------------------------- * Specific Player Character Trait. * ----------------------------------------------------------- */ if ( preg_match( '/^player:([0-9]+)$/', $scope, $playerMatch ) === 1 ) { $slot = (int)$playerMatch[1]; $stmt = $pdo->prepare( 'SELECT config FROM wf_games WHERE id = ? LIMIT 1' ); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); if ($raw === false) { return null; } $config = wayfarerRuntimeDecodeTraitConfig($raw); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId, 'player_traits' ); if (!is_array($trait)) { return null; } $playerName = trim( (string)( $runtimeState['players'][(string)$slot]['name'] ?? ('Player ' . ($slot + 1)) ) ); return wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, $playerName ); } return null; } function wayfarerRuntimeBuildEffectChangeEvents( PDO $pdo, int $gameId, array $runtimeState, array $reports ): array { $changes = []; foreach ($reports as $report) { if ( !is_array($report) || empty($report['applied']) ) { continue; } $metadata = wayfarerRuntimeFeedbackTargetMetadata( $pdo, $gameId, $runtimeState, $report ); if (!is_array($metadata)) { continue; } $before = $report['before'] ?? null; $after = $report['after'] ?? null; $kind = (string)($metadata['kind'] ?? 'trait'); $label = trim((string)($metadata['label'] ?? '')); if ($label === '') { continue; } if ($kind === 'party_membership') { if (!is_numeric($before) || !is_numeric($after)) { continue; } $beforeMember = (float)$before > 0; $afterMember = (float)$after > 0; if ($beforeMember === $afterMember) { continue; } $joined = $afterMember; $changes[] = [ 'kind' => 'party_membership', 'direction' => $joined ? 'gain' : 'loss', 'label' => $label, 'before' => $beforeMember ? 1 : 0, 'after' => $afterMember ? 1 : 0, 'delta' => $joined ? 1 : -1, 'text' => $label . ($joined ? ' joined your party.' : ' left your party.'), 'aria_text' => $label . ($joined ? ' joined your party.' : ' left your party.'), ]; continue; } $dataType = strtolower( trim((string)($metadata['data_type'] ?? 'number')) ); if ($dataType === 'boolean') { if (!is_numeric($before) || !is_numeric($after)) { continue; } $beforeBool = (float)$before > 0; $afterBool = (float)$after > 0; if ($beforeBool === $afterBool) { continue; } $changes[] = [ 'kind' => $kind, 'direction' => $afterBool ? 'gain' : 'loss', 'label' => $label, 'before' => $beforeBool ? 1 : 0, 'after' => $afterBool ? 1 : 0, 'delta' => $afterBool ? 1 : -1, 'text' => $label . ': ' . ($afterBool ? 'On' : 'Off'), 'aria_text' => $label . ' is now ' . ($afterBool ? 'on.' : 'off.'), ]; continue; } if (!is_numeric($before) || !is_numeric($after)) { continue; } $deltaFloat = (float)$after - (float)$before; if (abs($deltaFloat) < 0.000000000001) { continue; } $delta = wayfarerNormalizeRuntimeNumber($deltaFloat); $amount = wayfarerNormalizeRuntimeNumber(abs($deltaFloat)); $gain = $deltaFloat > 0; $text = ($gain ? '+' : '-') . (string)$amount . ' ' . $label; if ($kind === 'item_quantity') { $ariaText = ($gain ? 'Gained ' : 'Lost ') . (string)$amount . ' ' . $label . '.'; } else { $ariaText = $label . ($gain ? ' increased by ' : ' decreased by ') . (string)$amount . '.'; } $changes[] = [ 'kind' => $kind, 'direction' => $gain ? 'gain' : 'loss', 'label' => $label, 'before' => $before, 'after' => $after, 'delta' => $delta, 'text' => $text, 'aria_text' => $ariaText, ]; } return $changes; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECT_RUNTIME_V42 | | These are event Effects, not Tick Effects. The caller decides that a | Location has been triggered and invokes this function exactly once for that | trigger. Effects execute top-to-bottom against current runtime state. | */ function wayfarerApplyLocationEffects( PDO $pdo, int $gameId, array $location, array &$runtimeState ): array { $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeTraitConfig( $location['config'] ?? null ); $effects = $locationConfig['effects'] ?? []; if (!is_array($effects)) { $effects = []; } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false ) { $regionConfig = wayfarerRuntimeDecodeTraitConfig( $rawRegionConfig ); } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeOneTimeEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_location_effects'] = [ 'location_id' => $locationId, 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), 'reports' => $reports, /* WAYFARER_LOCATION_EFFECT_FEEDBACK_V70 */ 'changes' => wayfarerRuntimeBuildEffectChangeEvents( $pdo, $gameId, $runtimeState, $reports ), 'bound_repairs' => $boundRepairs, ]; return $reports; } /* |-------------------------------------------------------------------------- | Current Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECT_RUNTIME_V35 | | Linear Regions resolve a current Location by exact authored Position, | matching the player-scene resolver. Location Tick Effects run after Region | Tick Effects, so movement performed by the Region can cause arrival effects | at the newly reached Location on the same tick. | */ function wayfarerRuntimeFindCurrentLocation( PDO $pdo, int $gameId, array $runtimeState ): ?array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ( $gameId <= 0 || $regionId <= 0 ) { return null; } $position = $runtimeState['navigation']['position'] ?? null; if (!is_numeric($position)) { return null; } $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($locations as $location) { $config = []; if ( !empty( $location['config'] ) ) { $decoded = json_decode( (string)$location['config'], true ); if (is_array($decoded)) { $config = $decoded; } } if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } if ( (float)$config['position'] === (float)$position ) { $location['_runtime_config'] = $config; /* * WAYFARER_LOCATION_CONDITIONS_CURRENT_V45 */ if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } return $location; } } return null; } function wayfarerApplyCurrentLocationTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } /* * Being between authored Locations is normal. Record an empty result * rather than treating the absence of a Location as an error. */ if (!$location) { $runtimeState['runtime']['last_location_id'] = null; $runtimeState['runtime']['last_location_tick_effects'] = []; return []; } $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : []; $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); /* * Location effects may target Current Region Traits, so load the active * Region's Trait definitions rather than the Location's authored Traits. */ $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false && $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $locationEffects = $locationConfig['tick_effects'] ?? []; if (!is_array($locationEffects)) { $locationEffects = []; } $reports = []; foreach ( $locationEffects as $effect ) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } $runtimeState['runtime']['last_location_id'] = $locationId; $runtimeState['runtime']['last_location_tick_effects'] = $reports; $runtimeState['runtime']['last_location_trait_bound_repairs'] = $boundRepairs; return $reports; } function wayfarerApplyCurrentRegionTickEffects( PDO $pdo, array &$runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ($regionId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active navigation region.', ] ]; /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Reports are returned for the current request, not persisted. */ return $reports; } $stmt = $pdo->prepare( " SELECT id, game_id, config FROM wf_regions WHERE id = ? LIMIT 1 " ); $stmt->execute([ $regionId ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { $reports = [ [ 'applied' => false, 'error' => 'Active Region ' . $regionId . ' was not found.', ] ]; /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Reports are returned for the current request, not persisted. */ return $reports; } $config = []; if (!empty($region['config'])) { $decoded = json_decode( $region['config'], true ); if (is_array($decoded)) { $config = $decoded; } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, (int)( $region['game_id'] ?? 0 ), $config ); /* * Enforce the invariant before any branch condition is evaluated. * This also repairs state produced by older runtime versions. */ $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $regionId ); $effects = $config['tick_effects'] ?? []; if (!is_array($effects)) { $effects = []; } $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed tick effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $regionId, $traitDefinitions ); } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Tick reports and bound-repair reports are immediate diagnostics only. */ return $reports; } ================================================================================ FILE: _update_backups/v71-20260928-133621/play/includes/runtime-bootstrap.php ================================================================================ ================================================================================ FILE: _update_backups/v71-20260928-133621/play/includes/runtime.php ================================================================================ $mode, 'starting_day' => max(1, (int)($raw['starting_day'] ?? 1)), 'ticks_per_day' => max(1, (int)($raw['ticks_per_day'] ?? 1)), 'starting_hour' => max(0, (int)($raw['starting_hour'] ?? 8)), 'ticks_per_hour' => max(1, (int)($raw['ticks_per_hour'] ?? 1)), 'hours_per_day' => max(1, (int)($raw['hours_per_day'] ?? 24)), ]; if ($config['starting_hour'] >= $config['hours_per_day']) { $config['starting_hour'] = $config['hours_per_day'] - 1; } return $config; } function wayfarerRuntimeInitializeClockFromConfig( array $gameConfig, array &$runtimeState ): void { if (!isset($runtimeState['runtime']) || !is_array($runtimeState['runtime'])) { $runtimeState['runtime'] = []; } $config = wayfarerRuntimeTimeConfig($gameConfig); if ($config['mode'] === 'none') { unset($runtimeState['runtime']['clock']); return; } $clock = [ 'mode' => $config['mode'], 'day' => $config['starting_day'], ]; if ($config['mode'] === 'days') { $clock['tick_in_day'] = 0; } else { $clock['hour'] = $config['starting_hour']; $clock['tick_in_hour'] = 0; } $runtimeState['runtime']['clock'] = $clock; } function wayfarerRuntimeLoadGameConfig(PDO $pdo, int $gameId): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare('SELECT config FROM wf_games WHERE id = ? LIMIT 1'); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); return wayfarerRuntimeDecodeConfig($raw === false ? null : $raw); } function wayfarerRuntimeEnsureClock( PDO $pdo, int $gameId, array &$runtimeState ): array { $gameConfig = wayfarerRuntimeLoadGameConfig($pdo, $gameId); $timeConfig = wayfarerRuntimeTimeConfig($gameConfig); $clock = $runtimeState['runtime']['clock'] ?? null; if ($timeConfig['mode'] === 'none') { unset($runtimeState['runtime']['clock']); return $timeConfig; } if (!is_array($clock) || (string)($clock['mode'] ?? '') !== $timeConfig['mode']) { wayfarerRuntimeInitializeClockFromConfig($gameConfig, $runtimeState); } return $timeConfig; } function wayfarerRuntimeAdvanceClock( PDO $pdo, int $gameId, array &$runtimeState ): void { $config = wayfarerRuntimeEnsureClock($pdo, $gameId, $runtimeState); if ($config['mode'] === 'none') { return; } $clock =& $runtimeState['runtime']['clock']; $clock['day'] = max(1, (int)($clock['day'] ?? $config['starting_day'])); if ($config['mode'] === 'days') { $clock['tick_in_day'] = max(0, (int)($clock['tick_in_day'] ?? 0)) + 1; if ($clock['tick_in_day'] >= $config['ticks_per_day']) { $clock['day']++; $clock['tick_in_day'] = 0; } return; } $clock['hour'] = max(0, (int)($clock['hour'] ?? $config['starting_hour'])); $clock['tick_in_hour'] = max(0, (int)($clock['tick_in_hour'] ?? 0)) + 1; if ($clock['tick_in_hour'] >= $config['ticks_per_hour']) { $clock['tick_in_hour'] = 0; $clock['hour']++; if ($clock['hour'] >= $config['hours_per_day']) { $clock['hour'] = 0; $clock['day']++; } } } function wayfarerRuntimeClockLabel(array $runtimeState, array $timeConfig): string { if ($timeConfig['mode'] === 'none') { return ''; } $clock = $runtimeState['runtime']['clock'] ?? []; $day = max(1, (int)($clock['day'] ?? $timeConfig['starting_day'])); if ($timeConfig['mode'] === 'days') { return 'Day ' . $day; } $hour = max(0, (int)($clock['hour'] ?? $timeConfig['starting_hour'])); if ($timeConfig['hours_per_day'] === 24) { $displayHour = $hour % 12; if ($displayHour === 0) { $displayHour = 12; } $suffix = $hour < 12 ? 'AM' : 'PM'; return 'Day ' . $day . ' · ' . $displayHour . ':00 ' . $suffix; } return 'Day ' . $day . ' · Hour ' . $hour; } function wayfarerRuntimeLinearTravelDirection(array $regionConfig): int { $effects = isset($regionConfig['tick_effects']) && is_array($regionConfig['tick_effects']) ? $regionConfig['tick_effects'] : []; foreach ($effects as $effect) { if (!is_array($effect)) { continue; } if ( (string)($effect['target_scope'] ?? '') !== 'navigation' || (string)($effect['target_type'] ?? '') !== 'position' || (string)($effect['target_id'] ?? '') !== 'position' ) { continue; } $branches = wayfarerRuntimeTickEffectBranches($effect); foreach ($branches as $branch) { $operation = strtolower(trim((string)($branch['operation'] ?? ''))); if ($operation === 'subtract') { return -1; } if ($operation === 'add') { return 1; } } } return 1; } function wayfarerRuntimeBuildTravelStatus( PDO $pdo, int $gameId, array &$runtimeState ): array { $gameConfig = wayfarerRuntimeLoadGameConfig($pdo, $gameId); $timeConfig = wayfarerRuntimeEnsureClock($pdo, $gameId, $runtimeState); $result = [ 'time_label' => wayfarerRuntimeClockLabel($runtimeState, $timeConfig), 'region_name' => '', 'distance_label' => 'units', 'next_location_id' => 0, 'next_location_name' => '', 'distance_to_next' => null, 'next_label' => '', ]; $regionId = (int)($runtimeState['navigation']['region_id'] ?? 0); $position = $runtimeState['navigation']['position'] ?? null; if ($regionId <= 0 || !is_numeric($position)) { return $result; } $stmt = $pdo->prepare( 'SELECT id, name, navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1' ); $stmt->execute([$regionId, $gameId]); $region = $stmt->fetch(PDO::FETCH_ASSOC); if (!$region) { return $result; } $result['region_name'] = trim((string)($region['name'] ?? '')); if (strtolower(trim((string)($region['navigation_type'] ?? ''))) !== 'linear') { return $result; } $regionConfig = wayfarerRuntimeDecodeConfig($region['config'] ?? null); $distanceLabel = trim((string)($regionConfig['distance_label'] ?? 'units')); $result['distance_label'] = $distanceLabel !== '' ? $distanceLabel : 'units'; $direction = wayfarerRuntimeLinearTravelDirection($regionConfig); $current = (float)$position; $locationStmt = $pdo->prepare( "SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id" ); $locationStmt->execute([$regionId]); $best = null; $bestDistance = null; foreach ($locationStmt->fetchAll(PDO::FETCH_ASSOC) as $location) { $locationConfig = wayfarerRuntimeDecodeConfig($location['config'] ?? null); if (!isset($locationConfig['position']) || !is_numeric($locationConfig['position'])) { continue; } $locationPosition = (float)$locationConfig['position']; $ahead = $direction < 0 ? $locationPosition < $current : $locationPosition > $current; if (!$ahead) { continue; } $location['_runtime_config'] = $locationConfig; if (!wayfarerRuntimeLocationConditionsPass($pdo, $gameId, $location, $runtimeState)) { continue; } $distance = abs($locationPosition - $current); if ($best === null || $distance < $bestDistance) { $best = $location; $bestDistance = $distance; } } if ($best !== null) { $distance = wayfarerNormalizeRuntimeNumber((float)$bestDistance); $name = trim((string)($best['name'] ?? 'Location')) ?: 'Location'; $result['next_location_id'] = (int)($best['id'] ?? 0); $result['next_location_name'] = $name; $result['distance_to_next'] = $distance; $result['next_label'] = $name . ' — ' . $distance . ' ' . $result['distance_label']; } return $result; } function wayfarerRuntimeActiveTraitDefinitions( array $config ): array { $traits = $config['traits'] ?? []; if (!is_array($traits)) { return []; } $result = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( array_key_exists( 'active', $trait ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $trait['_runtime_id'] = $traitId; $result[] = $trait; } return $result; } function wayfarerRuntimeInitialTraitValue( array $trait ) { if ( array_key_exists( 'initial', $trait ) ) { return $trait['initial']; } $type = strtolower( trim( (string)( $trait['type'] ?? '' ) ) ); if ( $type === 'number' || $type === 'binary' || $type === 'boolean' ) { return 0; } return null; } function wayfarerRuntimeInitialTraitState( array $config ): array { $state = []; foreach ( wayfarerRuntimeActiveTraitDefinitions( $config ) as $trait ) { $traitId = (string)$trait['_runtime_id']; $state[$traitId] = wayfarerRuntimeInitialTraitValue( $trait ); } return $state; } /* |-------------------------------------------------------------------------- | Persisted state for every Region |-------------------------------------------------------------------------- | WAYFARER_ALL_REGION_STATE_V46 | | Game-wide Conditions may inspect a Region that the player has never visited. | Therefore every authored Region needs persistent Trait state, not only the | current Region. | | This helper is additive/recovery-safe: | - existing runtime values are never overwritten; | - missing Regions or newly-added Traits receive their authored initial; | - stale historical values are not destructively removed here. | */ function wayfarerRuntimeEnsureAllRegionTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['regions']) || !is_array($runtimeState['regions']) ) { $runtimeState['regions'] = []; } $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? ORDER BY id " ); $stmt->execute([ $gameId ]); $regions = $stmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($regions as $region) { $regionId = (int)( $region['id'] ?? 0 ); if ($regionId <= 0) { continue; } $regionKey = (string)$regionId; $config = wayfarerRuntimeDecodeConfig( $region['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset( $runtimeState['regions'][$regionKey] ) || !is_array( $runtimeState['regions'][$regionKey] ) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset( $runtimeState['regions'][$regionKey]['traits'] ) || !is_array( $runtimeState['regions'][$regionKey]['traits'] ) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['regions'][$regionKey]['traits'] ) ) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $initialValue; } } } /* |-------------------------------------------------------------------------- | Persisted state for every Location | WAYFARER_ALL_LOCATION_STATE_V56 |-------------------------------------------------------------------------- | | Locations can own Traits, so universal Conditions/Effects need persistent | per-playthrough Location Trait state just like Regions. | */ function wayfarerRuntimeEnsureAllLocationTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['locations']) || !is_array($runtimeState['locations']) ) { $runtimeState['locations'] = []; } $stmt = $pdo->prepare( " SELECT l.id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $location ) { $locationId = (int)($location['id'] ?? 0); if ($locationId <= 0) { continue; } $locationKey = (string)$locationId; $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset($runtimeState['locations'][$locationKey]) || !is_array($runtimeState['locations'][$locationKey]) ) { $runtimeState['locations'][$locationKey] = []; } if ( !isset($runtimeState['locations'][$locationKey]['traits']) || !is_array($runtimeState['locations'][$locationKey]['traits']) ) { $runtimeState['locations'][$locationKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['locations'][$locationKey]['traits'] ) ) { continue; } $runtimeState['locations'][$locationKey]['traits'][$traitId] = $initialValue; } } } function wayfarerRuntimeLoadStartingRegion( PDO $pdo, int $gameId, ?array $gameConfig = null ): ?array { if ($gameId <= 0) { return null; } if ($gameConfig === null) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { return null; } $gameConfig = wayfarerRuntimeDecodeConfig( $rawConfig ); } $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT * FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $startingRegionId, $gameId, ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); return $region ?: null; } function wayfarerRuntimeInitializeGame( PDO $pdo, int $gameUserId, array $setupInput = [] ): array { if ($gameUserId <= 0) { throw new RuntimeException( 'A valid game-user ID is required to initialize the game.' ); } try { $pdo->beginTransaction(); /* * Lock the playthrough row so two initialize requests cannot create * competing starting states. */ $stmt = $pdo->prepare( " SELECT gu.id, gu.game_id, gu.user_id, gu.role, gu.state, g.config AS game_config, g.player_slots AS player_slots FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id WHERE gu.id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } /* * Initialization is intentionally one-way. To restart a development * game, clear wf_game_users.state to NULL first. */ if ( $row['state'] !== null && $row['state'] !== '' ) { throw new RuntimeException( 'This game-user already has runtime state.' ); } $gameId = (int)$row['game_id']; $gameConfig = wayfarerRuntimeDecodeConfig( $row['game_config'] ?? null ); $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { throw new RuntimeException( 'This Game does not have a Starting Region configured.' ); } $startingRegion = wayfarerRuntimeLoadStartingRegion( $pdo, $gameId, $gameConfig ); if (!$startingRegion) { throw new RuntimeException( 'The configured Starting Region does not exist for this Game.' ); } $regionConfig = wayfarerRuntimeDecodeConfig( $startingRegion['config'] ?? null ); $gameTraits = wayfarerRuntimeInitialTraitState( $gameConfig ); $regionTraits = wayfarerRuntimeInitialTraitState( $regionConfig ); $navigation = [ 'region_id' => (int)$startingRegion['id'], ]; /* * Linear travel currently begins at coordinate zero. * Other navigation modes will get their own authored start-state * rules when those modes are implemented. */ if ( strtolower( trim( (string)( $startingRegion['navigation_type'] ?? '' ) ) ) === 'linear' ) { $navigation['position'] = 0; } $regionKey = (string)$startingRegion['id']; $state = [ /* * WAYFARER_RUNTIME_SCHEMA_V55 * Version the persisted playthrough contract explicitly. */ 'schema_version' => 1, 'game' => [ 'traits' => $gameTraits, ], 'navigation' => $navigation, 'regions' => [ $regionKey => [ 'traits' => $regionTraits, ], ], 'runtime' => [ 'tick' => 0, ], ]; /* WAYFARER_INITIALIZE_GAME_CLOCK_V61 */ wayfarerRuntimeInitializeClockFromConfig( $gameConfig, $state ); /* * WAYFARER_ALL_REGION_STATE_V46 * * Global conditions can inspect Region state before that Region has * ever been visited. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $state ); /* WAYFARER_INITIALIZE_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_V49 * * Items and Characters receive canonical per-playthrough state before * any starting setup effects are applied. */ wayfarerRuntimeEnsureEntityState( $pdo, $gameId, $state ); /* * WAYFARER_APPLY_STARTING_CHOICES_V36 * * Validate all player-entered setup data before persisting anything. * Then apply the selected options' starting effects to the canonical * initial runtime state. */ $preparedSetup = wayfarerRuntimePrepareStartingSetup( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $setupInput ); /* * WAYFARER_PLAYER_TRAITS_RUNTIME_V52 * * Persist the validated player-character names before creating the * per-player Trait state. Each slot receives independent values. */ $state['setup'] = [ 'player_names' => $preparedSetup['player_names'], 'choices' => $preparedSetup['choices'], ]; wayfarerRuntimeEnsureGameTraitState( $gameConfig, $state ); wayfarerRuntimeEnsurePlayerState( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $state ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $startingEffectReports = []; foreach ( $preparedSetup['effects'] as $startingEffect ) { /* WAYFARER_STARTING_CHOICE_RANDOM_RUNTIME_V58 */ $resolvedStartingValue = wayfarerRuntimeRollEffectValue( $startingEffect ); if (!$resolvedStartingValue['valid']) { throw new RuntimeException( 'Unable to resolve a Starting Choice effect value: ' . $resolvedStartingValue['error'] ); } $concreteStartingEffect = $startingEffect; $concreteStartingEffect['value'] = $resolvedStartingValue['value']; $report = wayfarerApplyRuntimeEffect( $state, $concreteStartingEffect, (int)$startingRegion['id'], $traitDefinitions ); if ( !empty($report['error']) ) { throw new RuntimeException( 'Unable to apply a Starting Choice effect: ' . $report['error'] ); } $report['value_mode'] = $resolvedStartingValue['mode']; $report['rolled_value'] = $resolvedStartingValue['value']; if ($resolvedStartingValue['mode'] === 'random') { $report['random_min'] = $resolvedStartingValue['min']; $report['random_max'] = $resolvedStartingValue['max']; } $startingEffectReports[] = $report; } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * * Starting-effect reports are intentionally not persisted. The effects * themselves have already mutated canonical state. */ /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 * * A game that begins exactly on an active Location should present that * Location before ordinary Action Mode. */ $startingLocation = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $state ); if ($startingLocation) { /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Starting on a Location is a real Location trigger. Apply its * one-time Effects before opening Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, $gameId, $startingLocation, $state ); $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$startingLocation['id'], 'phase' => 'presentation', 'trigger' => 'initial', 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /* WAYFARER_GAME_ENDINGS_INITIAL_V45 */ wayfarerRuntimeEvaluateGameEndings( $pdo, $gameId, $state ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => $gameId, 'starting_region_id' => (int)$startingRegion['id'], 'state' => $state, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Resolve a game-user record for the tick prototype. * * Preferred: * explicit wf_game_users.id * * Temporary development fallback: * first wf_game_users record. * * The fallback exists only because Wayfarer has not yet wired the play * page to authentication / game selection. Once that exists, remove the * fallback and always pass the active game_user_id explicitly. */ function wayfarerRuntimeResolveGameUserId( PDO $pdo, ?int $requestedId = null ): int { if ($requestedId !== null && $requestedId > 0) { $stmt = $pdo->prepare( " SELECT id FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $requestedId ]); $id = $stmt->fetchColumn(); if ($id !== false) { return (int)$id; } throw new RuntimeException( 'The requested game-user record does not exist.' ); } $id = $pdo ->query( " SELECT id FROM wf_game_users ORDER BY id LIMIT 1 " ) ->fetchColumn(); if ($id === false) { throw new RuntimeException( 'No wf_game_users record exists.' ); } return (int)$id; } /** * Load one game-user runtime state. */ function wayfarerRuntimeLoad( PDO $pdo, int $gameUserId ): array { $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'state' => $state, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /** * Advance exactly one tick. * * This is intentionally the entire v14 game engine: * * state.runtime.tick += 1 * * The transaction and row lock are already in place so later work can add * movement/effects without changing the request architecture. */ /* |-------------------------------------------------------------------------- | Linear Location crossing | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | A tick may move farther than the distance to the next authored Location. | This resolver finds the FIRST active Location crossed in the direction of | travel. The movement is then clamped to that exact authored coordinate. | | Examples: | 45 -> 55, Location 50 => stop at 50 | 55 -> 45, Location 50 => stop at 50 | | The starting coordinate is intentionally exclusive so leaving a Location | does not immediately trigger that same Location again. | */ /* |-------------------------------------------------------------------------- | Location Conditions | WAYFARER_LOCATION_CONDITIONS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeLocationConditionsPass( PDO $pdo, int $gameId, array $location, array &$runtimeState ): bool { $config = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $conditions = $config['conditions'] ?? null; $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; return wayfarerConditionsPass( $conditions, $resolver ); } /* |-------------------------------------------------------------------------- | Game Endings | WAYFARER_GAME_ENDINGS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeEvaluateGameEndings( PDO $pdo, int $gameId, array &$runtimeState ): ?array { /* * WAYFARER_ALL_REGION_STATE_ENDINGS_V46 */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $runtimeState ); /* WAYFARER_RECOVER_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $runtimeState ); if ( isset( $runtimeState['runtime']['ending'] ) && is_array( $runtimeState['runtime']['ending'] ) ) { return $runtimeState['runtime']['ending']; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); $config = wayfarerRuntimeDecodeConfig( $raw === false ? null : $raw ); $endings = $config['endings'] ?? []; if (!is_array($endings)) { return null; } $ordered = []; foreach ( array_values($endings) as $index => $ending ) { if (!is_array($ending)) { continue; } $ending['_runtime_order'] = $index; $ordered[] = $ending; } usort( $ordered, function ( array $a, array $b ): int { $priorityCompare = (int)( $b['priority'] ?? 0 ) <=> (int)( $a['priority'] ?? 0 ); if ($priorityCompare !== 0) { return $priorityCompare; } return (int)( $a['_runtime_order'] ?? 0 ) <=> (int)( $b['_runtime_order'] ?? 0 ); } ); $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; foreach ($ordered as $ending) { if ( !wayfarerConditionsPass( $ending['conditions'] ?? null, $resolver ) ) { continue; } $terminal = [ 'id' => (string)( $ending['id'] ?? '' ), 'title' => (string)( $ending['title'] ?? 'The End' ), 'text' => (string)( $ending['text'] ?? '' ), 'image_media_id' => (int)( $ending['image_media_id'] ?? 0 ), 'priority' => (int)( $ending['priority'] ?? 0 ), 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), ]; if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['ending'] = $terminal; unset( $runtimeState['runtime']['presentation'] ); return $terminal; } return null; } function wayfarerRuntimeFindCrossedLocation( PDO $pdo, int $gameId, int $regionId, $fromPosition, $toPosition, array &$runtimeState ): ?array { /* * WAYFARER_CROSSED_LOCATION_STATE_FIX_V46_1 * * Location Conditions are evaluated against the live runtime state. */ if ( $gameId <= 0 || $regionId <= 0 || !is_numeric($fromPosition) || !is_numeric($toPosition) ) { return null; } $from = (float)$fromPosition; $to = (float)$toPosition; if ($from === $to) { return null; } /* * Crossing interception currently applies only to Linear Regions. */ $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); $forward = $to > $from; $candidate = null; $candidatePosition = null; foreach ($locations as $location) { $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $locationPosition = (float)$config['position']; /* * The starting point is exclusive; the destination is inclusive. */ $crossed = $forward ? ( $locationPosition > $from && $locationPosition <= $to ) : ( $locationPosition < $from && $locationPosition >= $to ); if (!$crossed) { continue; } $location['_runtime_config'] = $config; if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } if ($candidate === null) { $candidate = $location; $candidatePosition = $locationPosition; } else { $nearer = $forward ? ( $locationPosition < $candidatePosition ) : ( $locationPosition > $candidatePosition ); $samePositionLowerId = $locationPosition === $candidatePosition && (int)$location['id'] < (int)$candidate['id']; if ( $nearer || $samePositionLowerId ) { $candidate = $location; $candidatePosition = $locationPosition; } } } if ($candidate === null) { return null; } $candidate['_runtime_position'] = wayfarerNormalizeRuntimeNumber( (float)$candidatePosition ); $candidate['_runtime_config'] = wayfarerRuntimeDecodeConfig( $candidate['config'] ?? null ); return $candidate; } /* |-------------------------------------------------------------------------- | Player spatial context |-------------------------------------------------------------------------- | WAYFARER_PLAYER_CONTEXT_V46_1 | | This is the canonical player-facing spatial label for Action Mode: | | exact eligible Location -> Location name + Region parent context | otherwise -> Region name | | It is returned by runtime APIs so the browser does not keep displaying the | Location name from the page's original server render after movement. | */ function wayfarerRuntimePlayerContext( PDO $pdo, int $gameId, array $runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionName = ''; if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $rawRegionName = $stmt->fetchColumn(); if ($rawRegionName !== false) { $regionName = trim( (string)$rawRegionName ); } } $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); $locationId = 0; $locationName = ''; if ($location) { $locationId = (int)( $location['id'] ?? 0 ); $locationName = trim( (string)( $location['name'] ?? '' ) ); } $contextName = $locationName !== '' ? $locationName : ( $regionName !== '' ? $regionName : 'Actions' ); return [ 'region_id' => $regionId, 'region_name' => $regionName, 'location_id' => $locationId, 'location_name' => $locationName, 'context_name' => $contextName, 'parent_context' => $locationName !== '' ? $regionName : '', ]; } /* |-------------------------------------------------------------------------- | Presentation dismissal |-------------------------------------------------------------------------- | | Presentation is persisted runtime state. This function is intentionally | generic even though v40 renders only Location presentations; Storylets can | use the same mechanism later. | */ function wayfarerRuntimeDismissPresentation( PDO $pdo, int $gameUserId, string $expectedType = '', int $expectedId = 0 ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); $presentation = $state['runtime']['presentation'] ?? null; if ( !is_array($presentation) || empty($presentation['type']) || empty($presentation['id']) ) { throw new RuntimeException( 'There is no active presentation to continue.' ); } if ( $expectedType !== '' && (string)$presentation['type'] !== $expectedType ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } if ( $expectedId > 0 && (int)$presentation['id'] !== $expectedId ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } /* WAYFARER_STORYLET_BRANCH_DISMISS_GUARD_V59 */ if ( (string)($presentation['type'] ?? '') === 'storylet' ) { $eligibleBranches = wayfarerRuntimeEligibleStoryletBranches( $pdo, (int)$row['game_id'], (int)($presentation['id'] ?? 0), $state ); if ($eligibleBranches) { throw new RuntimeException( 'Choose an available Storylet Branch before continuing.' ); } } $state['runtime']['last_presentation'] = $presentation; unset( $state['runtime']['presentation'] ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'dismissed' => $presentation, /* WAYFARER_STORYLET_ACTIONS_AFTER_PRESENTATION_V59 */ 'actions' => wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$row['game_id'], $state ), ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Advance exactly one tick. * * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ function wayfarerRuntimeAdvanceTick( PDO $pdo, int $gameUserId ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } /* * WAYFARER_GAME_ENDINGS_TERMINAL_V45 * * Endings are terminal runtime state. */ if ( isset( $state['runtime']['ending'] ) && is_array( $state['runtime']['ending'] ) ) { throw new RuntimeException( 'This game has ended.' ); } /* * Narrative presentation is a true runtime pause, not merely a hidden * browser control. Even Author Mode cannot advance ticks through it. */ if ( isset( $state['runtime']['presentation'] ) && is_array( $state['runtime']['presentation'] ) ) { throw new RuntimeException( 'Continue the current presentation before advancing time.' ); } /* * WAYFARER_ALL_REGION_STATE_TICK_V46 * * Recovery path for playthroughs initialized before v46, and for * Regions/Traits authored after a playthrough began. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_LOCATION_STATE_TICK_RECOVERY_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, (int)$row['game_id'], $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_TICK_V49 * * Recovery path for playthroughs created before V49 and for Entities * or Traits authored after play began. */ wayfarerRuntimeEnsureEntityState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_PLAYER_TRAITS_TICK_RECOVERY_V52 */ wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$row['game_id'], $state ); $regionIdBeforeTick = (int)( $state['navigation']['region_id'] ?? 0 ); $positionBeforeTick = $state['navigation']['position'] ?? null; $currentTick = (int)( $state['runtime']['tick'] ?? 0 ); $state['runtime']['tick'] = $currentTick + 1; /* WAYFARER_ADVANCE_GAME_CLOCK_V61 */ wayfarerRuntimeAdvanceClock( $pdo, (int)$row['game_id'], $state ); /* * Global Game Tick Effects run first. */ wayfarerApplyGameTickEffects( $pdo, (int)$row['game_id'], $state ); /* * Current Region Tick Effects then perform Region-level movement and * state changes. */ wayfarerApplyCurrentRegionTickEffects( $pdo, $state ); /* * Resolve whether the completed movement would have crossed an active * Location. If so, stop at the nearest crossed Location rather than * accepting the overshot position. */ $regionIdAfterMovement = (int)( $state['navigation']['region_id'] ?? 0 ); $positionAfterMovement = $state['navigation']['position'] ?? null; $crossedLocation = null; /* * We only interpret movement as a continuous path while the player * remains in the same Region. Region transitions will later get their * own explicit navigation semantics. */ if ( $regionIdBeforeTick > 0 && $regionIdBeforeTick === $regionIdAfterMovement ) { $crossedLocation = wayfarerRuntimeFindCrossedLocation( $pdo, (int)$row['game_id'], $regionIdAfterMovement, $positionBeforeTick, $positionAfterMovement, $state ); } $arrivalPosition = null; if ($crossedLocation) { $arrivalPosition = $crossedLocation[ '_runtime_position' ]; $state['navigation']['position'] = $arrivalPosition; /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Event Effects execute once at the trigger boundary. They run * before the Location's Tick Effects and before Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, (int)$row['game_id'], $crossedLocation, $state ); } /* * WAYFARER_LOCATION_EFFECTS_ONLY_RUNTIME_V43 * * Locations no longer own Tick Effects. Game and Region Tick Effects * have already advanced the simulation; a crossed Location contributes * its one-time Effects and then interrupts with Presentation Mode. */ if ($crossedLocation) { $state['navigation']['position'] = $arrivalPosition; $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$crossedLocation['id'], 'phase' => 'presentation', 'trigger' => 'arrival', 'tick' => (int)$state['runtime']['tick'], ]; $state['runtime']['last_location_arrival'] = [ 'location_id' => (int)$crossedLocation['id'], 'region_id' => $regionIdAfterMovement, 'position' => $arrivalPosition, 'tick' => (int)$state['runtime']['tick'], ]; } /* WAYFARER_GAME_ENDINGS_AFTER_TICK_V45 */ $ending = wayfarerRuntimeEvaluateGameEndings( $pdo, (int)$row['game_id'], $state ); $state['runtime']['last_tick_at'] = gmdate('c'); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId ]); $pdo->commit(); $presentation = $state['runtime']['presentation'] ?? null; $pausedForPresentation = is_array($presentation) && !empty($presentation['type']) && !empty($presentation['id']); $ending = $state['runtime']['ending'] ?? null; $ended = is_array($ending) && !empty($ending['id']); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'tick' => (int)$state['runtime']['tick'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'pause' => $pausedForPresentation || $ended, 'pause_reason' => $ended ? 'Game ended.' : ( $pausedForPresentation ? 'Location reached.' : '' ), 'presentation' => $pausedForPresentation ? $presentation : null, 'ending' => $ended ? $ending : null, /* WAYFARER_STORYLET_ACTIONS_TICK_API_V59 */ 'actions' => !$pausedForPresentation && !$ended ? wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$row['game_id'], $state ) : [], /* WAYFARER_TRAVEL_TIME_HUD_V61 */ 'travel' => wayfarerRuntimeBuildTravelStatus( $pdo, (int)$row['game_id'], $state ), ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: _update_backups/v72-20260928-150651/assets/js/builder.js ================================================================================ const wayfarerConditionData = window.wayfarerConditionData || {}; function updateConditionalConfigFields() { document .querySelectorAll('[data-config-field]') .forEach(function (container) { const relationshipName = container.dataset.conditionRelationship; const requiredValue = container.dataset.conditionEquals; if (!relationshipName) { container.style.display = ''; return; } const relationshipSelect = document.querySelector( '[data-relationship="' + relationshipName + '"]' ); if (!relationshipSelect) { container.style.display = 'none'; return; } const selectedId = relationshipSelect.value; const actualValue = wayfarerConditionData[relationshipName]?.[selectedId]; container.style.display = actualValue === requiredValue ? '' : 'none'; }); } document .querySelectorAll('[data-relationship]') .forEach(function (select) { select.addEventListener( 'change', updateConditionalConfigFields ); }); // ================================================================ // SYSTEM TRAIT GROUPS // ================================================================ function updateSystemTraitGroups(container) { const selectorField = container.dataset.selectorField; if (!selectorField) { return; } const selector = document.querySelector( '[name="' + selectorField + '"]' ); if (!selector) { return; } const selectedGroup = selector.value; container .querySelectorAll('[data-system-trait-group]') .forEach(function (group) { const active = group.dataset.systemTraitGroup === selectedGroup; group.style.display = active ? '' : 'none'; group .querySelectorAll('input, select, textarea') .forEach(function (input) { input.disabled = !active; }); }); } document .querySelectorAll('[data-system-trait-settings]') .forEach(function (container) { const selectorField = container.dataset.selectorField; const selector = selectorField ? document.querySelector( '[name="' + selectorField + '"]' ) : null; if (selector) { selector.addEventListener( 'change', function () { updateSystemTraitGroups(container); } ); } updateSystemTraitGroups(container); }); // ================================================================ // COLLECTION SORTING // ================================================================ document .querySelectorAll('[data-sort-column]') .forEach(function (button) { button.addEventListener('click', function () { const table = button.closest('[data-collection-table]'); if (!table) { return; } const tbody = table.querySelector('tbody'); const columnName = button.dataset.sortColumn; const sortType = button.dataset.sortType || 'text'; const currentDirection = button.dataset.sortDirection || 'none'; const nextDirection = currentDirection === 'asc' ? 'desc' : 'asc'; table .querySelectorAll('[data-sort-column]') .forEach(function (otherButton) { otherButton.dataset.sortDirection = 'none'; }); button.dataset.sortDirection = nextDirection; const rows = Array.from(tbody.querySelectorAll('tr')); rows.sort(function (a, b) { const aCell = a.querySelector( '[data-column="' + columnName + '"]' ); const bCell = b.querySelector( '[data-column="' + columnName + '"]' ); const aValue = aCell?.dataset.sortValue ?? ''; const bValue = bCell?.dataset.sortValue ?? ''; let comparison = 0; if (sortType === 'number') { const aNumber = aValue === '' ? Number.POSITIVE_INFINITY : Number(aValue); const bNumber = bValue === '' ? Number.POSITIVE_INFINITY : Number(bValue); comparison = aNumber - bNumber; } else { comparison = aValue.localeCompare( bValue, undefined, { numeric: true, sensitivity: 'base' } ); } return nextDirection === 'asc' ? comparison : -comparison; }); rows.forEach(function (row) { tbody.appendChild(row); }); }); }); updateConditionalConfigFields(); // ================================================================ // MEDIA LIBRARY CONTROLS // ================================================================ function updateBuilderMediaSlot(slotContainer) { const checkedAction = slotContainer.querySelector( 'input[type="radio"][name^="media_action"]:checked' ); const action = checkedAction ? checkedAction.value : 'none'; const existingSelect = slotContainer.querySelector( '[data-media-existing]' ); if (existingSelect) { existingSelect.disabled = action !== 'existing'; } slotContainer .querySelectorAll( '[data-media-upload-fields] input' ) .forEach(function (input) { input.disabled = action !== 'upload'; }); } document .querySelectorAll('[data-media-slot]') .forEach(function (slotContainer) { slotContainer .querySelectorAll( 'input[type="radio"][name^="media_action"]' ) .forEach(function (radio) { radio.addEventListener( 'change', function () { updateBuilderMediaSlot( slotContainer ); } ); }); updateBuilderMediaSlot( slotContainer ); }); // ================================================================ // AUTHORED OBJECT CONTEXT HEADING // WAYFARER_OBJECT_CONTEXT_HEADING_V34 // ================================================================ function initializeBuilderObjectContextHeading() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } const heading = editor.querySelector( '[data-builder-object-heading]' ); if (!heading) { return; } const fieldName = heading.dataset.builderObjectLabelField || 'name'; const form = editor.querySelector( 'form' ); if (!form) { return; } const field = Array .from( form.elements ) .find(function (element) { return ( element && element.name === fieldName ); }); if (!field) { return; } const context = heading.querySelector( '[data-builder-object-context]' ); const label = heading.querySelector( '[data-builder-object-context-label]' ); if (!context || !label) { return; } function refreshContextHeading() { const value = String( field.value || '' ).trim(); label.textContent = value; context.hidden = value === ''; } field.addEventListener( 'input', refreshContextHeading ); field.addEventListener( 'change', refreshContextHeading ); refreshContextHeading(); } initializeBuilderObjectContextHeading(); // ================================================================ // AUTHORED OBJECT EDITOR TABS // ================================================================ function initializeBuilderEditorTabs() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } /* * The editor component has one primary save form. The separate delete * form, when present, lives later in the editor pane and is deliberately * left outside the tab system. */ const form = Array .from(editor.children) .find(function (child) { return child.tagName === 'FORM'; }); if ( !form || form.dataset.builderTabbed === '1' ) { return; } form.dataset.builderTabbed = '1'; form.classList.add( 'builder-object-form' ); /* * WAYFARER_ACTIVE_TAB_V20 * * Persist the selected editor tab through POST/redirect/reload. */ const activeTabInput = document.createElement( 'input' ); activeTabInput.type = 'hidden'; activeTabInput.name = 'active_tab'; const requestedTab = new URLSearchParams( window.location.search ).get( 'tab' ) || 'basics'; activeTabInput.value = requestedTab; form.appendChild( activeTabInput ); const originalChildren = Array.from( form.children ); const panels = { basics: document.createElement('div'), media: document.createElement('div'), traits: document.createElement('div'), /* WAYFARER_CONDITIONS_PANEL_V45_1 */ conditions: document.createElement('div'), tick_effects: document.createElement('div'), /* WAYFARER_STORYLET_BUILDER_V56 */ /* WAYFARER_STORYLET_BRANCH_PANEL_KEY_V57 */ branches: document.createElement('div') }; panels.basics.className = 'builder-tab-panel'; panels.media.className = 'builder-tab-panel'; panels.traits.className = 'builder-tab-panel'; panels.conditions.className = 'builder-tab-panel'; panels.branches.className = 'builder-tab-panel'; panels.basics.dataset.builderPanel = 'basics'; panels.media.dataset.builderPanel = 'media'; panels.traits.dataset.builderPanel = 'traits'; panels.conditions.dataset.builderPanel = 'conditions'; panels.branches.dataset.builderPanel = 'branches'; panels.tick_effects.className = 'builder-tab-panel'; panels.tick_effects.dataset.builderPanel = 'tick_effects'; let section = 'basics'; let saveContainer = null; let hasMedia = false; let hasTraits = false; let hasConditions = false; let hasTickEffects = false; let hasBranches = false; originalChildren.forEach( function (child) { /* * Keep hidden record identifiers directly in the form. */ if ( child.tagName === 'INPUT' && child.type === 'hidden' ) { return; } /* * Save becomes a stable footer beneath the tab panels. */ const submitButton = child.querySelector ? child.querySelector( 'button[type="submit"], input[type="submit"]' ) : null; if (submitButton) { saveContainer = child; return; } /* * Ignore the separator immediately before a tab heading. * Other HR elements are harmless but unnecessary inside the * cleaned-up editor. */ if (child.tagName === 'HR') { child.remove(); return; } if (child.tagName === 'H3') { const heading = child.textContent .trim() .toLowerCase(); if (heading === 'media') { section = 'media'; hasMedia = true; child.remove(); return; } if (heading === 'traits') { section = 'traits'; hasTraits = true; child.remove(); return; } } /* * WAYFARER_CONDITIONS_PANEL_V45_1 * * Conditions answer whether an authored object is eligible; * Effects answer what changes when it triggers. */ if ( child.hasAttribute && child.hasAttribute( 'data-builder-conditions-panel' ) ) { section = 'conditions'; hasConditions = true; } /* * WAYFARER_ONE_TIME_EFFECTS_EDITOR_V42 * * Location Effects and Tick Effects share one Effects panel. */ if ( child.hasAttribute && ( child.hasAttribute( 'data-builder-effects-panel' ) || child.hasAttribute( 'data-builder-tick-effects' ) ) ) { section = 'tick_effects'; hasTickEffects = true; } /* WAYFARER_STORYLET_BRANCHES_PANEL_V56 */ if ( child.hasAttribute && child.hasAttribute( 'data-builder-branches-panel' ) ) { section = 'branches'; hasBranches = true; } /* * Config fields belong to Basics even though the old v9 Media * markup happens to precede them in the source template. */ if ( child.hasAttribute && child.hasAttribute( 'data-config-field' ) ) { section = 'basics'; } /* * A Media fieldset is definitive even if future markup changes * around the Media heading. */ if ( child.hasAttribute && child.hasAttribute( 'data-media-slot' ) ) { section = 'media'; hasMedia = true; } panels[section] .appendChild( child ); } ); /* * If Traits exist, anything moved after the Traits heading belongs there. * System traits do not use that heading and therefore stay in Basics. */ if ( panels.traits.children.length > 0 ) { hasTraits = true; } if ( panels.media.children.length > 0 ) { hasMedia = true; } if ( panels.conditions.children.length > 0 ) { hasConditions = true; } if ( panels.tick_effects.children.length > 0 ) { hasTickEffects = true; } if ( panels.branches.children.length > 0 ) { hasBranches = true; } const tabBar = document.createElement( 'div' ); tabBar.className = 'builder-editor-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const availablePanels = [ { key: 'basics', label: 'Basics', show: true }, { key: 'media', label: 'Media', show: hasMedia }, { key: 'traits', label: 'Traits', show: hasTraits }, { key: 'conditions', label: 'Conditions', show: hasConditions }, { key: 'tick_effects', label: 'Effects', show: hasTickEffects }, { key: 'branches', label: 'Branches', show: hasBranches } ].filter( function (item) { return item.show; } ); function activatePanel(panelKey) { activeTabInput.value = panelKey; tabBar .querySelectorAll( '[data-builder-tab]' ) .forEach( function (button) { const active = button.dataset.builderTab === panelKey; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); Object .entries(panels) .forEach( function (entry) { const key = entry[0]; const panel = entry[1]; panel.hidden = key !== panelKey; } ); } availablePanels.forEach( function (item) { const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'builder-editor-tab'; button.dataset.builderTab = item.key; button.setAttribute( 'role', 'tab' ); button.textContent = item.label; button.addEventListener( 'click', function () { activatePanel( item.key ); } ); tabBar.appendChild( button ); } ); /* * Insert the new structure after hidden inputs so all form fields remain * part of the same form and submit exactly as before. */ form.appendChild( tabBar ); availablePanels.forEach( function (item) { form.appendChild( panels[item.key] ); } ); if (saveContainer) { saveContainer.classList.add( 'builder-save-bar' ); form.appendChild( saveContainer ); } const initialPanelKey = availablePanels.some( function (item) { return item.key === requestedTab; } ) ? requestedTab : 'basics'; activatePanel( initialPanelKey ); } initializeBuilderEditorTabs(); // ================================================================ // SHARED GAMEPLAY TICK EFFECTS CONTROLS v25 // WAYFARER_GAMEPLAY_MODULAR_V25 // ================================================================ function initializeTickEffects() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } const wrapper = editor.querySelector( '[data-builder-tick-effects]' ); if (!wrapper) { return; } if ( wrapper.dataset.tickEffectsReady === '1' ) { return; } wrapper.dataset.tickEffectsReady = '1'; // ------------------------------------------------------------ // ROW ADD / REMOVE // ------------------------------------------------------------ const list = wrapper.querySelector( '[data-tick-effects-list]' ); const addButton = wrapper.querySelector( '[data-add-tick-effect]' ); const template = wrapper.querySelector( '[data-tick-effect-template]' ); let nextIndex = 0; wrapper .querySelectorAll( '[data-tick-effect-row]' ) .forEach( function (row) { const index = Number( row.dataset.tickIndex ); if ( Number.isFinite(index) && index >= nextIndex ) { nextIndex = index + 1; } } ); function updateLinearTargetAvailability() { const navigationSelect = editor.querySelector( 'select[name="navigation_type"]' ); const navigationType = navigationSelect ? navigationSelect.value : wrapper.dataset.navigationType; const linear = navigationType === 'linear'; wrapper .querySelectorAll( '[data-linear-navigation-target]' ) .forEach( function (option) { option.disabled = !linear; } ); if (!linear) { wrapper .querySelectorAll( '[data-tick-effect-target]' ) .forEach( function (select) { if ( select.value === 'navigation:position' ) { select.value = ''; } } ); } } if ( addButton && list && template ) { addButton.addEventListener( 'click', function () { const html = template.innerHTML.replace( /__INDEX__/g, String( nextIndex++ ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const row = temporary.firstElementChild; if (!row) { return; } list.appendChild( row ); updateLinearTargetAvailability(); } ); } wrapper.addEventListener( 'click', function (event) { const removeButton = event.target.closest( '[data-remove-tick-effect]' ); if (!removeButton) { return; } const row = removeButton.closest( '[data-tick-effect-row]' ); if (row) { row.remove(); } } ); const navigationSelect = editor.querySelector( 'select[name="navigation_type"]' ); if (navigationSelect) { navigationSelect.addEventListener( 'change', updateLinearTargetAvailability ); } updateLinearTargetAvailability(); /* * WAYFARER_EMBEDDED_TICK_EFFECTS_V33 * * Game Settings already owns a Gameplay tab. In embedded mode we keep * this shared editor exactly where PHP rendered it while retaining the * normal Tick Effect, condition, and result-branch controls. */ if ( wrapper.hasAttribute( 'data-tick-effects-embedded' ) ) { return; } // ------------------------------------------------------------ // MOVE THE SECTION INTO ITS OWN BUILDER TAB // ------------------------------------------------------------ const form = wrapper.closest( 'form' ); if (!form) { return; } const tabBar = form.querySelector( '.builder-editor-tabs' ); if (!tabBar) { /* * Builder without the v10 tab layout: * leave the Tick Effects section inline. */ return; } if ( form.querySelector( '[data-builder-panel="tick_effects"]' ) ) { return; } const panel = document.createElement( 'div' ); panel.className = 'builder-tab-panel'; panel.dataset.builderPanel = 'tick_effects'; panel.hidden = true; panel.appendChild( wrapper ); const saveBar = form.querySelector( '.builder-save-bar' ); if (saveBar) { form.insertBefore( panel, saveBar ); } else { form.appendChild( panel ); } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'builder-editor-tab'; button.dataset.builderTab = 'tick_effects'; button.setAttribute( 'role', 'tab' ); button.setAttribute( 'aria-selected', 'false' ); /* WAYFARER_GAMEPLAY_TAB_LABEL_V25_1 */ button.textContent = 'Effects'; tabBar.appendChild( button ); function activateTickEffectsTab() { tabBar .querySelectorAll( '[data-builder-tab]' ) .forEach( function (tabButton) { const active = tabButton === button; tabButton.classList.toggle( 'is-active', active ); tabButton.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); form .querySelectorAll( '[data-builder-panel]' ) .forEach( function (tabPanel) { tabPanel.hidden = tabPanel.dataset.builderPanel !== 'tick_effects'; } ); } button.addEventListener( 'click', activateTickEffectsTab ); /* * The original tab controller does not know about the panel added * afterward, so explicitly hide our panel whenever an original tab * is clicked. */ tabBar .querySelectorAll( '[data-builder-tab]:not([data-builder-tab="tick_effects"])' ) .forEach( function (originalButton) { originalButton.addEventListener( 'click', function () { panel.hidden = true; button.classList.remove( 'is-active' ); button.setAttribute( 'aria-selected', 'false' ); } ); } ); } // ================================================================ // SHARED ONE-TIME EFFECTS CONTROLS // WAYFARER_ONE_TIME_EFFECTS_EDITOR_V42 // WAYFARER_GENERALIZED_EFFECT_EDITOR_V43 // ================================================================ function wayfarerEffectConditionTargets( wrapper ) { const targetJson = wrapper.querySelector( '[data-effect-condition-targets-json]' ); if (!targetJson) { return {}; } try { return JSON.parse( targetJson.textContent || '{}' ); } catch (error) { return {}; } } /* WAYFARER_DATATYPE_EFFECT_UI_V58 */ function wayfarerEffectTargetCatalog( wrapper ) { const targetJson = wrapper ? wrapper.querySelector( '[data-effect-target-catalog-json]' ) : null; if (!targetJson) { return {}; } try { return JSON.parse( targetJson.textContent || '{}' ); } catch (error) { return {}; } } function wayfarerEffectTargetDefinition( branch ) { const effectRow = branch ? branch.closest( '[data-effect-row]' ) : null; const wrapper = effectRow ? effectRow.closest( '[data-builder-effects]' ) : null; const targetSelect = effectRow ? effectRow.querySelector( '[data-effect-target]' ) : null; const catalog = wayfarerEffectTargetCatalog( wrapper ); return targetSelect ? (catalog[targetSelect.value] || {}) : {}; } function wayfarerEffectEnsureChoiceSelect( fixedWrap, numberInput ) { if (!fixedWrap || !numberInput) { return null; } let select = fixedWrap.querySelector( '[data-effect-fixed-choice]' ); if (!select) { select = document.createElement( 'select' ); select.dataset.effectFixedChoice = '1'; select.name = numberInput.name; select.hidden = true; select.disabled = true; fixedWrap.appendChild( select ); } return select; } function wayfarerEffectBuildConditionEditor( host, name, targets ) { const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( name, targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } function wayfarerEffectUpdateResultFields( branch ) { const operation = branch.querySelector( '[data-effect-branch-operation]' ); const modeWrap = branch.querySelector( '[data-effect-value-mode-wrap]' ); const mode = branch.querySelector( '[data-effect-value-mode]' ); const fixedWrap = branch.querySelector( '[data-effect-fixed-wrap]' ); const randomWrap = branch.querySelector( '[data-effect-random-wrap]' ); if (!operation) { return; } const definition = wayfarerEffectTargetDefinition( branch ); let dataType = String( definition.data_type || definition.trait_type || 'number' ).toLowerCase(); if (dataType === 'binary') { dataType = 'boolean'; } const semanticType = String( definition.semantic_type || 'trait' ); const allowedOperations = Array.isArray( definition.effect_operations ) && definition.effect_operations.length ? definition.effect_operations : ( dataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); const supportsRandom = Object.prototype.hasOwnProperty.call( definition, 'supports_random' ) ? Boolean( definition.supports_random ) : dataType === 'number'; const operationLabels = { set: 'Set', add: semanticType === 'item_quantity' ? 'Add' : 'Add', subtract: semanticType === 'item_quantity' ? 'Subtract' : 'Subtract', none: 'No Change' }; const previousOperation = operation.value; const resultOperations = allowedOperations.concat( ['none'] ); operation.innerHTML = ''; resultOperations.forEach( function (operationValue) { const option = document.createElement( 'option' ); option.value = operationValue; option.textContent = operationLabels[operationValue] || operationValue; operation.appendChild( option ); } ); operation.value = resultOperations.includes( previousOperation ) ? previousOperation : allowedOperations[0]; const noChange = operation.value === 'none'; let valueMode = mode ? mode.value : 'fixed'; if (!supportsRandom) { valueMode = 'fixed'; if (mode) { mode.value = 'fixed'; } } if (modeWrap) { modeWrap.hidden = noChange || !supportsRandom; modeWrap .querySelectorAll( 'select, input' ) .forEach( function (field) { field.disabled = noChange || !supportsRandom; } ); } const showFixed = !noChange && ( !supportsRandom || valueMode === 'fixed' ); if (fixedWrap) { fixedWrap.hidden = !showFixed; const numberInput = fixedWrap.querySelector( 'input[name]' ); const choiceSelect = wayfarerEffectEnsureChoiceSelect( fixedWrap, numberInput ); const valueOptions = definition.value_options && typeof definition.value_options === 'object' ? definition.value_options : null; if ( showFixed && valueOptions && numberInput && choiceSelect ) { const previousValue = choiceSelect.hidden ? numberInput.value : choiceSelect.value; choiceSelect.innerHTML = ''; Object .entries(valueOptions) .forEach( function (entry) { const option = document.createElement( 'option' ); option.value = entry[0]; option.textContent = entry[1]; choiceSelect.appendChild( option ); } ); if ( Object.prototype.hasOwnProperty.call( valueOptions, String(previousValue) ) ) { choiceSelect.value = String(previousValue); } choiceSelect.hidden = false; choiceSelect.disabled = false; numberInput.hidden = true; numberInput.disabled = true; } else { if ( choiceSelect && numberInput && !choiceSelect.hidden ) { numberInput.value = choiceSelect.value; } if (choiceSelect) { choiceSelect.hidden = true; choiceSelect.disabled = true; } if (numberInput) { numberInput.hidden = !showFixed; numberInput.disabled = !showFixed; numberInput.type = dataType === 'number' ? 'number' : 'text'; if (dataType === 'number') { numberInput.step = 'any'; } else { numberInput.removeAttribute( 'step' ); } } } } if (randomWrap) { const showRandom = !noChange && supportsRandom && valueMode === 'random'; randomWrap.hidden = !showRandom; randomWrap .querySelectorAll( 'input' ) .forEach( function (field) { field.disabled = !showRandom; } ); } } function wayfarerEffectReindexBranches( effectRow ) { const effectIndex = effectRow.dataset.effectIndex; const list = effectRow.querySelector( '[data-effect-branch-list]' ); if ( effectIndex === undefined || !list ) { return; } const branches = Array .from( list.children ) .filter( function (child) { return ( child.matches && child.matches( '[data-effect-branch]' ) ); } ); branches.forEach( function ( branch, branchIndex ) { branch.dataset.branchIndex = String( branchIndex ); branch .querySelectorAll( '[name]' ) .forEach( function (field) { field.name = field.name.replace( /effects\[\d+\]\[branches\]\[\d+\]/, 'effects[' + effectIndex + '][branches][' + branchIndex + ']' ); } ); } ); } function wayfarerEffectRefreshBranchControls( effectRow ) { const list = effectRow.querySelector( '[data-effect-branch-list]' ); if (!list) { return; } const branches = Array .from( list.children ) .filter( function (child) { return ( child.matches && child.matches( '[data-effect-branch]' ) ); } ); const conditionals = branches.filter( function (branch) { return ( branch.dataset.branchKind === 'conditional' ); } ); conditionals.forEach( function ( branch, index ) { const up = branch.querySelector( '[data-move-effect-branch-up]' ); const down = branch.querySelector( '[data-move-effect-branch-down]' ); if (up) { up.disabled = index === 0; } if (down) { down.disabled = index === conditionals.length - 1; } } ); const hasOtherwise = branches.some( function (branch) { return ( branch.dataset.branchKind === 'otherwise' ); } ); const addOtherwise = effectRow.querySelector( '[data-add-effect-otherwise]' ); if (addOtherwise) { addOtherwise.disabled = hasOtherwise; } branches.forEach( wayfarerEffectUpdateResultFields ); wayfarerEffectReindexBranches( effectRow ); } function initializeEffects() { document .querySelectorAll( '[data-builder-effects]' ) .forEach( function (wrapper) { if ( wrapper.dataset.effectsReady === '1' ) { return; } wrapper.dataset.effectsReady = '1'; const list = wrapper.querySelector( '[data-effects-list]' ); const addButton = wrapper.querySelector( '[data-add-effect]' ); const effectTemplate = wrapper.querySelector( '[data-effect-template]' ); const conditionalTemplate = wrapper.querySelector( '[data-effect-conditional-template]' ); const otherwiseTemplate = wrapper.querySelector( '[data-effect-otherwise-template]' ); const conditionTargets = wayfarerEffectConditionTargets( wrapper ); let nextIndex = 0; wrapper .querySelectorAll( '[data-effect-row]' ) .forEach( function (row) { const index = Number( row.dataset.effectIndex ); if ( Number.isFinite( index ) && index >= nextIndex ) { nextIndex = index + 1; } wayfarerEffectRefreshBranchControls( row ); } ); if ( addButton && list && effectTemplate ) { addButton.addEventListener( 'click', function () { const effectIndex = nextIndex++; const html = effectTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', String( effectIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const row = temporary.firstElementChild; if (!row) { return; } list.appendChild( row ); wayfarerEffectRefreshBranchControls( row ); } ); } wrapper.addEventListener( 'change', function (event) { const target = event.target.closest( '[data-effect-target]' ); if (target) { const effectRow = target.closest( '[data-effect-row]' ); if (effectRow) { wayfarerEffectRefreshBranchControls( effectRow ); } return; } const operation = event.target.closest( '[data-effect-branch-operation]' ); const valueMode = event.target.closest( '[data-effect-value-mode]' ); if ( !operation && !valueMode ) { return; } const branch = event.target.closest( '[data-effect-branch]' ); if (branch) { wayfarerEffectUpdateResultFields( branch ); } } ); wrapper.addEventListener( 'click', function (event) { const effectRow = event.target.closest( '[data-effect-row]' ); const removeEffect = event.target.closest( '[data-remove-effect]' ); if (removeEffect) { if (!effectRow) { return; } if ( !window.confirm( 'Remove this Effect?' ) ) { return; } effectRow.remove(); return; } if (!effectRow) { return; } const addConditional = event.target.closest( '[data-add-effect-conditional]' ); if (addConditional) { if (!conditionalTemplate) { return; } const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if (!branchList) { return; } const effectIndex = effectRow.dataset.effectIndex; const branchIndex = branchList .querySelectorAll( ':scope > [data-effect-branch]' ) .length; const html = conditionalTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } const otherwise = Array .from( branchList.children ) .find( function (child) { return ( child.dataset && child.dataset.branchKind === 'otherwise' ); } ); if (otherwise) { branchList.insertBefore( branch, otherwise ); } else { branchList.appendChild( branch ); } wayfarerEffectReindexBranches( effectRow ); const newIndex = branch.dataset.branchIndex; const host = branch.querySelector( '[data-new-effect-condition-editor]' ); if (host) { wayfarerEffectBuildConditionEditor( host, 'effects[' + effectIndex + '][branches][' + newIndex + '][conditions]', conditionTargets ); } wayfarerEffectRefreshBranchControls( effectRow ); return; } const addOtherwise = event.target.closest( '[data-add-effect-otherwise]' ); if (addOtherwise) { if (!otherwiseTemplate) { return; } const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if ( !branchList || branchList.querySelector( '[data-branch-kind="otherwise"]' ) ) { return; } const effectIndex = effectRow.dataset.effectIndex; const branchIndex = branchList .querySelectorAll( ':scope > [data-effect-branch]' ) .length; const html = otherwiseTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } branchList.appendChild( branch ); wayfarerEffectRefreshBranchControls( effectRow ); return; } const removeBranch = event.target.closest( '[data-remove-effect-branch]' ); if (removeBranch) { const branch = removeBranch.closest( '[data-effect-branch]' ); if (branch) { branch.remove(); wayfarerEffectRefreshBranchControls( effectRow ); } return; } const moveUp = event.target.closest( '[data-move-effect-branch-up]' ); const moveDown = event.target.closest( '[data-move-effect-branch-down]' ); if ( !moveUp && !moveDown ) { return; } const branch = event.target.closest( '[data-effect-branch]' ); const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if ( !branch || !branchList || branch.dataset.branchKind !== 'conditional' ) { return; } const conditionals = Array .from( branchList.children ) .filter( function (child) { return ( child.dataset && child.dataset.branchKind === 'conditional' ); } ); const index = conditionals.indexOf( branch ); if ( moveUp && index > 0 ) { branchList.insertBefore( branch, conditionals[ index - 1 ] ); } if ( moveDown && index >= 0 && index < conditionals.length - 1 ) { const next = conditionals[ index + 1 ]; branchList.insertBefore( next, branch ); } wayfarerEffectRefreshBranchControls( effectRow ); } ); } ); } initializeEffects(); initializeTickEffects();initializeTickEffects(); // ================================================================ // REUSABLE CONDITION EDITOR v27 // WAYFARER_CONDITION_EDITOR_V27 // ================================================================ function wayfarerConditionEscape(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function wayfarerConditionTargetOptions( targets ) { const groups = {}; Object .entries( targets || {} ) .forEach( function (entry) { const key = entry[0]; const target = entry[1] || {}; const group = target.group || 'Targets'; if (!groups[group]) { groups[group] = []; } const helper = String( target.helper || '' ).trim(); const baseLabel = target.label || key; groups[group].push({ key: key, label: helper !== '' ? baseLabel + ' — ' + helper : baseLabel }); } ); let html = ''; Object .entries(groups) .forEach( function (entry) { html += ''; entry[1] .forEach( function (target) { html += ''; } ); html += ''; } ); return html; } /* WAYFARER_DATATYPE_CONDITION_UI_V58 */ function wayfarerConditionOperatorLabel( operator ) { const labels = { equals: 'Equals', not_equals: 'Does not equal', greater_than: 'Greater than', greater_than_or_equal: 'Greater than or equal', less_than: 'Less than', less_than_or_equal: 'Less than or equal' }; return labels[operator] || operator; } function wayfarerConditionUpdateRow( row, targets ) { if (!row) { return; } const targetSelect = row.querySelector( '[data-condition-target]' ); const operatorSelect = row.querySelector( '[data-condition-operator]' ); const valueInput = row.querySelector( '[data-condition-value-input]' ); const valueSelect = row.querySelector( '[data-condition-value-select]' ); if (!targetSelect || !operatorSelect) { return; } const definition = (targets || {})[targetSelect.value] || {}; const dataType = String( definition.data_type || definition.trait_type || 'text' ).toLowerCase() === 'binary' ? 'boolean' : String( definition.data_type || definition.trait_type || 'text' ).toLowerCase(); const allowedOperators = Array.isArray( definition.condition_operators ) && definition.condition_operators.length ? definition.condition_operators : [ 'equals', 'not_equals', 'greater_than', 'greater_than_or_equal', 'less_than', 'less_than_or_equal' ]; const previousOperator = operatorSelect.value; operatorSelect.innerHTML = ''; allowedOperators.forEach( function (operator) { const option = document.createElement( 'option' ); option.value = operator; option.textContent = wayfarerConditionOperatorLabel( operator ); operatorSelect.appendChild( option ); } ); operatorSelect.value = allowedOperators.includes( previousOperator ) ? previousOperator : allowedOperators[0]; const valueOptions = definition.value_options && typeof definition.value_options === 'object' ? definition.value_options : null; if (valueOptions && valueSelect) { const previousValue = valueSelect.hidden && valueInput ? valueInput.value : valueSelect.value; valueSelect.innerHTML = ''; Object .entries(valueOptions) .forEach( function (entry) { const option = document.createElement( 'option' ); option.value = entry[0]; option.textContent = entry[1]; valueSelect.appendChild( option ); } ); if ( Object.prototype.hasOwnProperty.call( valueOptions, String(previousValue) ) ) { valueSelect.value = String(previousValue); } valueSelect.hidden = false; valueSelect.disabled = false; if (valueInput) { valueInput.hidden = true; valueInput.disabled = true; } return; } if ( valueSelect && valueInput && !valueSelect.hidden ) { valueInput.value = valueSelect.value; } if (valueSelect) { valueSelect.hidden = true; valueSelect.disabled = true; } if (valueInput) { valueInput.hidden = false; valueInput.disabled = false; valueInput.type = dataType === 'number' ? 'number' : 'text'; if (dataType === 'number') { valueInput.step = 'any'; } else { valueInput.removeAttribute( 'step' ); } } } function wayfarerConditionNextChildIndex( group ) { let maxIndex = -1; const children = Array .from( group.children ) .find( function (child) { return child.hasAttribute( 'data-condition-children' ); } ); if (children) { Array .from( children.children ) .forEach( function (node) { let index = Number( node.dataset.conditionChildIndex ); if (!Number.isFinite(index)) { const namedInput = node.querySelector( '[name]' ); const match = namedInput ? namedInput.name.match( /\[children\]\[(\d+)\]/ ) : null; index = match ? Number(match[1]) : -1; } if ( Number.isFinite(index) && index > maxIndex ) { maxIndex = index; } } ); } return maxIndex + 1; } function wayfarerConditionCreateCondition( name, targets, childIndex ) { const row = document.createElement( 'div' ); row.className = 'builder-condition-row'; row.dataset.conditionNode = '1'; row.dataset.conditionType = 'condition'; row.dataset.conditionChildIndex = String(childIndex); row.innerHTML = '' + '' + '' + '' + ''; return row; } function wayfarerConditionCreateGroup( name, targets, childIndex, isRoot ) { const group = document.createElement( 'div' ); group.className = 'builder-condition-group' + ( isRoot ? ' builder-condition-group-root' : '' ); group.dataset.conditionNode = '1'; group.dataset.conditionType = 'group'; if ( childIndex !== null && childIndex !== undefined ) { group.dataset.conditionChildIndex = String(childIndex); } group.innerHTML = '' + '
' + '' + ' of the following' + ( isRoot ? '' : '' ) + '
' + '
' + '
' + ' ' + '' + '
'; return group; } function wayfarerConditionInitializeEditor( editor, targets ) { if ( !editor || editor.dataset.conditionEditorReady === '1' ) { return; } editor.dataset.conditionEditorReady = '1'; editor.addEventListener( 'change', function (event) { const targetSelect = event.target.closest( '[data-condition-target]' ); if (!targetSelect) { return; } wayfarerConditionUpdateRow( targetSelect.closest( '[data-condition-type="condition"]' ), targets ); } ); editor .querySelectorAll( '[data-condition-type="condition"]' ) .forEach( function (row) { wayfarerConditionUpdateRow( row, targets ); } ); editor.addEventListener( 'click', function (event) { const removeButton = event.target.closest( '[data-remove-condition-node]' ); if (removeButton) { const node = removeButton.closest( '[data-condition-node]' ); if (node) { node.remove(); } return; } const addCondition = event.target.closest( '[data-add-condition]' ); const addGroup = event.target.closest( '[data-add-condition-group]' ); if ( !addCondition && !addGroup ) { return; } const group = event.target.closest( '[data-condition-type="group"]' ); if (!group) { return; } const children = Array .from( group.children ) .find( function (child) { return child.hasAttribute( 'data-condition-children' ); } ); if (!children) { return; } const groupTypeInput = Array .from( group.children ) .find( function (child) { return ( child.tagName === 'INPUT' && child.type === 'hidden' && child.name.endsWith( '[type]' ) ); } ); if (!groupTypeInput) { return; } const groupName = groupTypeInput.name.replace( /\[type\]$/, '' ); const nextIndex = wayfarerConditionNextChildIndex( group ); const childName = groupName + '[children][' + nextIndex + ']'; const newNode = addGroup ? wayfarerConditionCreateGroup( childName, targets, nextIndex, false ) : wayfarerConditionCreateCondition( childName, targets, nextIndex ); children.appendChild( newNode ); if (newNode.dataset.conditionType === 'condition') { wayfarerConditionUpdateRow( newNode, targets ); } } ); } function initializeConditionEditors() { /* * WAYFARER_GENERIC_CONDITION_INIT_V45_2 * * The Condition Editor is shared infrastructure. It must work anywhere it * is rendered, not only when nested inside Tick Effects. * * Server-rendered editors carry their own allowed-target JSON. Dynamic * Tick Effect / Effect editors continue to initialize themselves through * their existing creation paths. */ document .querySelectorAll( '[data-condition-editor]' ) .forEach( function (editor) { let targets = {}; const targetJson = editor.querySelector( '[data-condition-editor-targets-json]' ); if (targetJson) { try { targets = JSON.parse( targetJson.textContent || '{}' ); } catch (error) { targets = {}; } } wayfarerConditionInitializeEditor( editor, targets ); } ); /* * Tick Effects retain their observer because newly-created Tick Effect * rows are added after page load and are not server-rendered editors. */ document .querySelectorAll( '[data-builder-tick-effects]' ) .forEach( function (wrapper) { let targets = {}; const targetJson = wrapper.querySelector( '[data-condition-targets-json]' ); if (targetJson) { try { targets = JSON.parse( targetJson.textContent || '{}' ); } catch (error) { targets = {}; } } wrapper .querySelectorAll( '[data-condition-editor]' ) .forEach( function (editor) { wayfarerConditionInitializeEditor( editor, targets ); } ); /* * New Tick Effect rows are cloned from a template after page * load, so convert their placeholder into a fresh root group. */ const list = wrapper.querySelector( '[data-tick-effects-list]' ); if ( list && !list.dataset.conditionObserverReady ) { list.dataset.conditionObserverReady = '1'; const observer = new MutationObserver( function (mutations) { mutations.forEach( function (mutation) { mutation.addedNodes .forEach( function (addedNode) { if ( !addedNode.querySelectorAll ) { return; } const row = addedNode.matches && addedNode.matches( '[data-tick-effect-row]' ) ? addedNode : addedNode.querySelector( '[data-tick-effect-row]' ); if (!row) { return; } const host = row.querySelector( '[data-new-condition-editor]' ); if (!host) { return; } const index = row.dataset.tickIndex; const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( 'tick_effects[' + index + '][conditions]', targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } ); } ); } ); observer.observe( list, { childList: true, subtree: true } ); } } ); } initializeConditionEditors(); // ================================================================ // TICK EFFECT RESULT BRANCHES v28 // WAYFARER_TICK_EFFECT_BRANCHES_V28 // ================================================================ function wayfarerTickEffectConditionTargets( wrapper ) { const targetJson = wrapper.querySelector( '[data-condition-targets-json]' ); if (!targetJson) { return {}; } try { return JSON.parse( targetJson.textContent || '{}' ); } catch (error) { return {}; } } /* WAYFARER_DATATYPE_TICK_EFFECT_UI_V58 */ function wayfarerTickEffectTargetCatalog( wrapper ) { const targetJson = wrapper ? wrapper.querySelector( '[data-tick-target-catalog-json]' ) : null; if (!targetJson) { return {}; } try { return JSON.parse( targetJson.textContent || '{}' ); } catch (error) { return {}; } } function wayfarerTickEffectTargetDefinition( branch ) { const effectRow = branch ? branch.closest( '[data-tick-effect-row]' ) : null; const wrapper = effectRow ? effectRow.closest( '[data-builder-tick-effects]' ) : null; const targetSelect = effectRow ? effectRow.querySelector( '[data-tick-effect-target]' ) : null; const catalog = wayfarerTickEffectTargetCatalog( wrapper ); return targetSelect ? (catalog[targetSelect.value] || {}) : {}; } function wayfarerTickEffectEnsureValueMode( branch, valueInput ) { if (!branch || !valueInput) { return null; } let wrap = branch.querySelector( '[data-tick-value-mode-wrap]' ); if (!wrap) { wrap = document.createElement( 'label' ); wrap.dataset.tickValueModeWrap = '1'; wrap.appendChild( document.createTextNode( 'Value Type' ) ); const select = document.createElement( 'select' ); select.dataset.tickValueMode = '1'; select.name = valueInput.name.replace( /\[value\]$/, '[value_mode]' ); select.innerHTML = '' + ''; const fields = valueInput.closest( '.builder-tick-result-fields' ); if ( fields && fields.dataset.tickInitialValueMode === 'random' ) { select.value = 'random'; } wrap.appendChild( select ); const valueWrap = branch.querySelector( '[data-tick-branch-value-wrap]' ); if (valueWrap && valueWrap.parentNode) { valueWrap.parentNode.insertBefore( wrap, valueWrap ); } } return wrap; } function wayfarerTickEffectEnsureChoiceSelect( valueWrap, valueInput ) { if (!valueWrap || !valueInput) { return null; } let select = valueWrap.querySelector( '[data-tick-fixed-choice]' ); if (!select) { select = document.createElement( 'select' ); select.dataset.tickFixedChoice = '1'; select.name = valueInput.name; select.hidden = true; select.disabled = true; valueWrap.appendChild( select ); } return select; } function wayfarerTickEffectEnsureRandomWrap( branch, valueInput ) { if (!branch || !valueInput) { return null; } let wrap = branch.querySelector( '[data-tick-random-wrap]' ); if (!wrap) { wrap = document.createElement( 'div' ); wrap.className = 'builder-effect-random-range'; wrap.dataset.tickRandomWrap = '1'; const baseName = valueInput.name.replace( /\[value\]$/, '' ); wrap.innerHTML = '' + ''; const fields = valueInput.closest( '.builder-tick-result-fields' ); if (fields) { const minInput = wrap.querySelector( '[data-tick-random-min]' ); const maxInput = wrap.querySelector( '[data-tick-random-max]' ); if (minInput) { minInput.value = fields.dataset.tickInitialMin || ''; } if (maxInput) { maxInput.value = fields.dataset.tickInitialMax || ''; } } const valueWrap = branch.querySelector( '[data-tick-branch-value-wrap]' ); if (valueWrap && valueWrap.parentNode) { valueWrap.parentNode.insertBefore( wrap, valueWrap.nextSibling ); } } return wrap; } function wayfarerTickEffectUpdateBranchValue( branch ) { const operation = branch.querySelector( '[data-tick-branch-operation]' ); const valueWrap = branch.querySelector( '[data-tick-branch-value-wrap]' ); if ( !operation || !valueWrap ) { return; } const input = valueWrap.querySelector( 'input[name]' ); if (!input) { return; } const definition = wayfarerTickEffectTargetDefinition( branch ); let dataType = String( definition.data_type || definition.trait_type || 'number' ).toLowerCase(); if (dataType === 'binary') { dataType = 'boolean'; } const allowedOperations = Array.isArray( definition.effect_operations ) && definition.effect_operations.length ? definition.effect_operations : ( dataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); const supportsRandom = Object.prototype.hasOwnProperty.call( definition, 'supports_random' ) ? Boolean( definition.supports_random ) : dataType === 'number'; const previousOperation = operation.value; const resultOperations = allowedOperations.concat( ['none'] ); operation.innerHTML = ''; const operationLabels = { set: 'Set', add: 'Add', subtract: 'Subtract', none: 'No Change' }; resultOperations.forEach( function (operationValue) { const option = document.createElement( 'option' ); option.value = operationValue; option.textContent = operationLabels[operationValue] || operationValue; operation.appendChild( option ); } ); operation.value = resultOperations.includes( previousOperation ) ? previousOperation : allowedOperations[0]; const noChange = operation.value === 'none'; const modeWrap = wayfarerTickEffectEnsureValueMode( branch, input ); const mode = modeWrap ? modeWrap.querySelector( '[data-tick-value-mode]' ) : null; let valueMode = mode ? mode.value : 'fixed'; if (!supportsRandom) { valueMode = 'fixed'; if (mode) { mode.value = 'fixed'; } } if (modeWrap) { modeWrap.hidden = noChange || !supportsRandom; if (mode) { mode.disabled = noChange || !supportsRandom; } } const showFixed = !noChange && ( !supportsRandom || valueMode === 'fixed' ); valueWrap.hidden = !showFixed; const choiceSelect = wayfarerTickEffectEnsureChoiceSelect( valueWrap, input ); const valueOptions = definition.value_options && typeof definition.value_options === 'object' ? definition.value_options : null; if ( showFixed && valueOptions && choiceSelect ) { const previousValue = choiceSelect.hidden ? input.value : choiceSelect.value; choiceSelect.innerHTML = ''; Object .entries(valueOptions) .forEach( function (entry) { const option = document.createElement( 'option' ); option.value = entry[0]; option.textContent = entry[1]; choiceSelect.appendChild( option ); } ); if ( Object.prototype.hasOwnProperty.call( valueOptions, String(previousValue) ) ) { choiceSelect.value = String(previousValue); } choiceSelect.hidden = false; choiceSelect.disabled = false; input.hidden = true; input.disabled = true; } else { if ( choiceSelect && !choiceSelect.hidden ) { input.value = choiceSelect.value; } if (choiceSelect) { choiceSelect.hidden = true; choiceSelect.disabled = true; } input.hidden = !showFixed; input.disabled = !showFixed; input.type = dataType === 'number' ? 'number' : 'text'; if (dataType === 'number') { input.step = 'any'; } else { input.removeAttribute( 'step' ); } } const randomWrap = wayfarerTickEffectEnsureRandomWrap( branch, input ); if (randomWrap) { const showRandom = !noChange && supportsRandom && valueMode === 'random'; randomWrap.hidden = !showRandom; randomWrap .querySelectorAll( 'input' ) .forEach( function (field) { field.disabled = !showRandom; } ); } } function wayfarerTickEffectReindexBranches( effectRow ) { const effectIndex = effectRow.dataset.tickIndex; const list = effectRow.querySelector( '[data-tick-branch-list]' ); if ( effectIndex === undefined || !list ) { return; } const branches = Array.from( list.children ).filter( function (child) { return ( child.matches && child.matches( '[data-tick-effect-branch]' ) ); } ); branches.forEach( function ( branch, branchIndex ) { branch.dataset.branchIndex = String( branchIndex ); branch .querySelectorAll( '[name]' ) .forEach( function (field) { field.name = field.name.replace( /tick_effects\[\d+\]\[branches\]\[\d+\]/, 'tick_effects[' + effectIndex + '][branches][' + branchIndex + ']' ); } ); } ); } function wayfarerTickEffectRefreshBranchControls( effectRow ) { const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } const branches = Array.from( list.children ).filter( function (child) { return ( child.matches && child.matches( '[data-tick-effect-branch]' ) ); } ); const conditionalBranches = branches.filter( function (branch) { return ( branch.dataset.branchKind === 'conditional' ); } ); conditionalBranches.forEach( function ( branch, index ) { const up = branch.querySelector( '[data-move-tick-branch-up]' ); const down = branch.querySelector( '[data-move-tick-branch-down]' ); if (up) { up.disabled = index === 0; } if (down) { down.disabled = index === conditionalBranches.length - 1; } } ); const hasOtherwise = branches.some( function (branch) { return ( branch.dataset.branchKind === 'otherwise' ); } ); const addOtherwise = effectRow.querySelector( '[data-add-tick-otherwise-result]' ); if (addOtherwise) { addOtherwise.disabled = hasOtherwise; } branches.forEach( wayfarerTickEffectUpdateBranchValue ); wayfarerTickEffectReindexBranches( effectRow ); } function wayfarerTickEffectBuildConditionEditor( host, name, targets ) { const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( name, targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } function initializeTickEffectResultBranches() { document .querySelectorAll( '[data-builder-tick-effects]' ) .forEach( function (wrapper) { if ( wrapper.dataset.tickBranchesReady === '1' ) { return; } wrapper.dataset.tickBranchesReady = '1'; const conditionalTemplate = wrapper.querySelector( '[data-tick-conditional-branch-template]' ); const otherwiseTemplate = wrapper.querySelector( '[data-tick-otherwise-branch-template]' ); const conditionTargets = wayfarerTickEffectConditionTargets( wrapper ); wrapper .querySelectorAll( '[data-tick-effect-row]' ) .forEach( wayfarerTickEffectRefreshBranchControls ); wrapper.addEventListener( 'change', function (event) { const target = event.target.closest( '[data-tick-effect-target]' ); if (target) { const effectRow = target.closest( '[data-tick-effect-row]' ); if (effectRow) { wayfarerTickEffectRefreshBranchControls( effectRow ); } return; } const operation = event.target.closest( '[data-tick-branch-operation]' ); const valueMode = event.target.closest( '[data-tick-value-mode]' ); if (!operation && !valueMode) { return; } const branch = event.target.closest( '[data-tick-effect-branch]' ); if (branch) { wayfarerTickEffectUpdateBranchValue( branch ); } } ); wrapper.addEventListener( 'click', function (event) { const effectRow = event.target.closest( '[data-tick-effect-row]' ); if (!effectRow) { return; } const addConditional = event.target.closest( '[data-add-tick-conditional-result]' ); if (addConditional) { if (!conditionalTemplate) { return; } const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } const effectIndex = effectRow.dataset.tickIndex; const branchIndex = list.querySelectorAll( ':scope > [data-tick-effect-branch]' ).length; const html = conditionalTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } const otherwise = Array .from( list.children ) .find( function (child) { return ( child.dataset && child.dataset.branchKind === 'otherwise' ); } ); if (otherwise) { list.insertBefore( branch, otherwise ); } else { list.appendChild( branch ); } wayfarerTickEffectReindexBranches( effectRow ); const newIndex = branch.dataset.branchIndex; const host = branch.querySelector( '[data-new-branch-condition-editor]' ); if (host) { wayfarerTickEffectBuildConditionEditor( host, 'tick_effects[' + effectIndex + '][branches][' + newIndex + '][conditions]', conditionTargets ); } wayfarerTickEffectRefreshBranchControls( effectRow ); return; } const addOtherwise = event.target.closest( '[data-add-tick-otherwise-result]' ); if (addOtherwise) { if (!otherwiseTemplate) { return; } const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } if ( list.querySelector( '[data-branch-kind="otherwise"]' ) ) { return; } const effectIndex = effectRow.dataset.tickIndex; const branchIndex = list.querySelectorAll( ':scope > [data-tick-effect-branch]' ).length; const html = otherwiseTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } list.appendChild( branch ); wayfarerTickEffectRefreshBranchControls( effectRow ); return; } const removeBranch = event.target.closest( '[data-remove-tick-branch]' ); if (removeBranch) { const branch = removeBranch.closest( '[data-tick-effect-branch]' ); if (branch) { branch.remove(); wayfarerTickEffectRefreshBranchControls( effectRow ); } return; } const moveUp = event.target.closest( '[data-move-tick-branch-up]' ); const moveDown = event.target.closest( '[data-move-tick-branch-down]' ); if ( !moveUp && !moveDown ) { return; } const branch = event.target.closest( '[data-tick-effect-branch]' ); const list = effectRow.querySelector( '[data-tick-branch-list]' ); if ( !branch || !list || branch.dataset.branchKind !== 'conditional' ) { return; } const conditionals = Array.from( list.children ).filter( function (child) { return ( child.dataset && child.dataset.branchKind === 'conditional' ); } ); const index = conditionals.indexOf( branch ); if ( moveUp && index > 0 ) { list.insertBefore( branch, conditionals[index - 1] ); } if ( moveDown && index >= 0 && index < conditionals.length - 1 ) { const next = conditionals[index + 1]; list.insertBefore( next, branch ); } wayfarerTickEffectRefreshBranchControls( effectRow ); } ); /* * Existing Tick Effect code creates whole effect rows from its * template. Refresh branch controls after those rows appear. */ const list = wrapper.querySelector( '[data-tick-effects-list]' ); if (list) { const observer = new MutationObserver( function (mutations) { mutations.forEach( function (mutation) { mutation.addedNodes .forEach( function (node) { if ( !node.matches || !node.matches( '[data-tick-effect-row]' ) ) { return; } wayfarerTickEffectRefreshBranchControls( node ); } ); } ); } ); observer.observe( list, { childList: true } ); } } ); } initializeTickEffectResultBranches(); ================================================================================ FILE: _update_backups/v72-20260928-150651/includes/builder/editor.php ================================================================================ $groupValues) { if (!is_array($groupValues)) { continue; } foreach ($groupValues as $traitName => $traitValue) { if (!isset($storedSystemTraitValues[$groupName][$traitName])) { $storedSystemTraitValues[$groupName][$traitName] = $traitValue; } } } } } $renderSystemTraits = function () use ( $record, $fields, $systemTraitsSelector, $systemTraitGroups, $storedSystemTraitValues, $storedSystemTraitMaxValues ) { if (!$systemTraitsSelector || empty($systemTraitGroups)) { return; } $selectedGroup = $record[$systemTraitsSelector] ?? ($fields[$systemTraitsSelector]['default'] ?? ''); ?>
$groupDefinition): ?>
>

$traitDefinition): ?>

>

$relationship): ?> $field): ?>

Media

Optional. Reuse an existing asset from this game's Media Library, upload a new asset, or leave the slot empty.

$mediaDefinition): ?>

Maximum upload: .

Current: ()



No existing assets have been uploaded to this game yet.

$field): ?>




Traits

Traits are additional values associated with this that can change during play.


Danger Zone

Permanently delete this . This cannot be undone.

================================================================================ FILE: _update_backups/v72-20260928-150651/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: _update_backups/v72-20260928-150651/play/assets/play.js ================================================================================ /* |-------------------------------------------------------------------------- | PLAYER RUNTIME CONTROLLER | WAYFARER_ACTION_SCREEN_V39 | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | Runtime modes currently exposed to the player: | | presentation -> Action -> running navigation -> presentation/Action | | Presentation is persisted by PHP. Automatic tick advancement cannot continue | while a presentation is active. | */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; if (!config) { return; } const body = document.body; const tickValue = document.getElementById( 'wayfarer-tick-value' ); const statusValue = document.getElementById( 'wayfarer-tick-status' ); const message = document.getElementById( 'wayfarer-tick-message' ); const onceButton = document.getElementById( 'wayfarer-tick-once' ); const autoButton = document.getElementById( 'wayfarer-tick-auto' ); const authorPauseButton = document.getElementById( 'wayfarer-tick-pause' ); const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); /* WAYFARER_PLAYER_HUD_V64 */ const playerHud = document.querySelector( '[data-wayfarer-hud]' ); /* WAYFARER_TRAVEL_TIME_HUD_V61 */ const travelTime = document.querySelector( '[data-wayfarer-travel-time]' ); const travelTimeSeparator = document.querySelector( '[data-wayfarer-travel-time-separator]' ); const travelNext = document.querySelector( '[data-wayfarer-travel-next]' ); const continueTravelButton = document.querySelector( '[data-wayfarer-continue-travel]' ); const playerPauseButton = document.querySelector( '[data-wayfarer-pause-travel]' ); /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ const travelDirectionButtons = Array.from( document.querySelectorAll( '[data-wayfarer-travel-direction]' ) ); /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Action Mode owns one physical card. Inventory, Status, and future * utility/context views temporarily replace the normal action heading/list * inside that card without becoming persisted runtime modes. */ const actionHeading = actionScreen ? actionScreen.querySelector( '.wayfarer-action-heading' ) : null; const actionList = actionScreen ? actionScreen.querySelector( '[data-wayfarer-action-list]' ) : null; const actionSubviewPanels = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ) : []; const actionViewOpeners = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-open-action-view]' ) ) : []; const actionViewClosers = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-close-action-view]' ) ) : []; /* * Persisted narrative presentation. */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); const presentationContinueButton = presentationScreen ? presentationScreen.querySelector( '[data-wayfarer-presentation-continue]' ) : null; let running = false; let requestInFlight = false; let presentationRequestInFlight = false; let storyletRequestInFlight = false; let timeoutId = null; let reloadWhenIdle = false; /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ let requestedTravelDirection = Number( config.initialDirection ) < 0 ? -1 : 1; let presentationActive = Boolean( presentationScreen && !presentationScreen.hidden ); function setMessage( text, isError = false ) { if (!message) { return; } message.textContent = text; message.style.color = isError ? 'red' : ''; } function emitModeEvent() { let mode = 'paused'; if (presentationActive) { mode = 'presentation'; } else if (running) { mode = 'running'; } window.dispatchEvent( new CustomEvent( 'wayfarer:runtime-mode', { detail: { mode: mode } } ) ); /* * Keep the v39 event names available for existing development hooks. */ window.dispatchEvent( new CustomEvent( running ? 'wayfarer:runtime-running' : 'wayfarer:runtime-paused', { detail: { mode: mode } } ) ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Generic in-card view switcher. Future Map / Shop / other views can use * the same data-wayfarer-open-action-view + data-wayfarer-action-view * contract without adding another page-level layout system. */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Resolve available views at the moment navigation occurs instead of * relying on the V48 page-load snapshot. This supports nested Entity * detail screens and future context views added to the same Action card. */ function setActionView(viewName = 'actions') { if (!actionScreen) { return; } const requested = String( viewName || 'actions' ); const showActions = requested === 'actions'; const panels = Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ); let matched = showActions; panels.forEach( function (panel) { const active = panel.dataset .wayfarerActionView === requested; panel.hidden = !active; if (active) { matched = true; } } ); /* * Fail safely if a malformed/removed view name is requested. */ if (!matched) { panels.forEach( function (panel) { panel.hidden = true; } ); if (actionHeading) { actionHeading.hidden = false; } if (actionList) { actionList.hidden = false; } return; } if (actionHeading) { actionHeading.hidden = !showActions; } if (actionList) { actionList.hidden = !showActions; } } function renderControls() { if (statusValue) { statusValue.textContent = presentationActive ? 'Presentation' : ( running ? 'Running' : 'Paused' ); } if (onceButton) { onceButton.disabled = running || requestInFlight || presentationActive; } if (autoButton) { autoButton.disabled = running || requestInFlight || presentationActive; } if (authorPauseButton) { authorPauseButton.disabled = !running; } if (continueTravelButton) { continueTravelButton.disabled = running || requestInFlight || presentationActive; } document .querySelectorAll( '[data-wayfarer-storylet-action-id], ' + '[data-wayfarer-storylet-branch-id]' ) .forEach( function (button) { button.disabled = requestInFlight || presentationRequestInFlight || storyletRequestInFlight || ( button.hasAttribute( 'data-wayfarer-storylet-action-id' ) && (running || presentationActive) ); } ); if (playerPauseButton) { playerPauseButton.disabled = !running; } travelDirectionButtons.forEach( function (button) { button.disabled = running || requestInFlight || presentationActive; } ); if (actionScreen) { actionScreen.hidden = running || presentationActive; } if (travelControls) { travelControls.hidden = !running || presentationActive; } /* WAYFARER_PLAYER_HUD_V64 */ if (playerHud) { playerHud.hidden = presentationActive; } if (presentationScreen) { presentationScreen.hidden = !presentationActive; presentationScreen.setAttribute( 'aria-hidden', presentationActive ? 'false' : 'true' ); } body.classList.toggle( 'wayfarer-runtime-running', running ); body.classList.toggle( 'wayfarer-runtime-paused', !running && !presentationActive ); body.classList.toggle( 'wayfarer-runtime-presentation', presentationActive ); } /* * WAYFARER_LIVE_ACTION_CONTEXT_V46_1 * * Action Mode must reflect the current runtime position, not the page's * original server-rendered context. */ function renderPlayerContext(context) { if ( !context || typeof context !== 'object' ) { return; } const heading = document.querySelector( '[data-wayfarer-action-context-name]' ); const parent = document.querySelector( '[data-wayfarer-action-parent-context]' ); if (heading) { const name = String( context.context_name || 'Actions' ).trim(); heading.textContent = name || 'Actions'; } if (parent) { const parentText = String( context.parent_context || '' ).trim(); parent.textContent = parentText; parent.hidden = parentText === ''; } } /* WAYFARER_TRAVEL_TIME_HUD_RENDER_V61 */ function renderTravelStatus(travel) { if (!travel || typeof travel !== 'object') { return; } const timeLabel = String(travel.time_label || '').trim(); if (travelTime) { travelTime.textContent = timeLabel; travelTime.hidden = timeLabel === ''; } if (travelTimeSeparator) { travelTimeSeparator.hidden = timeLabel === ''; } const nextLabel = String(travel.next_label || '').trim(); if (travelNext) { travelNext.textContent = nextLabel === '' ? '' : 'Next: ' + nextLabel; travelNext.hidden = nextLabel === ''; } } function displayRuntimeValue(value) { if (typeof value === 'boolean') { return value ? 'True' : 'False'; } if ( value === null || value === undefined ) { return 'null'; } if (typeof value === 'object') { return JSON.stringify( value ); } return String( value ); } /* * WAYFARER_STORYLET_ACTIONS_LIVE_V59 * * Tick/presentation APIs return the currently eligible root Manual * Storylets so Action Mode does not depend on the page-load snapshot. */ function renderStoryletActions(actions) { if (!actionScreen) { return; } const host = actionScreen.querySelector( '[data-wayfarer-storylet-actions]' ); if (!host) { return; } const normalized = Array.isArray(actions) ? actions : []; host.innerHTML = ''; normalized.forEach( function (action) { const id = Number( action && action.id ? action.id : 0 ); if (id <= 0) { return; } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-action-button'; button.dataset.wayfarerStoryletActionId = String(id); button.textContent = String( action.label || action.name || 'Storylet' ); host.appendChild( button ); } ); const empty = actionScreen.querySelector( '[data-wayfarer-action-empty]' ); if (empty) { const canTravel = actionScreen.dataset.wayfarerCanTravel === '1'; empty.hidden = canTravel || host.children.length > 0; } renderControls(); } /* * WAYFARER_PLAYER_HUD_V64 * * HUD definitions remain Region-authored/server-rendered. Tick responses * already carry canonical state, so progress bars can update without adding * another API payload. */ function renderHudState(state) { if ( !playerHud || !state || typeof state !== 'object' ) { return; } playerHud .querySelectorAll( '[data-wayfarer-hud-component]' ) .forEach( function (component) { if ( component.dataset .wayfarerHudType !== 'progress_bar' ) { return; } const scope = String( component.dataset .wayfarerHudScope || '' ); const sourceId = String( component.dataset .wayfarerHudSourceId || '' ); const regionId = String( component.dataset .wayfarerHudRegionId || '' ); let traits = null; if ( scope === 'game' ) { traits = state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : null; } else if ( scope === 'region' ) { traits = state.regions && state.regions[regionId] && state.regions[regionId].traits && typeof state.regions[regionId].traits === 'object' ? state.regions[regionId].traits : null; } if ( !traits || !Object.prototype .hasOwnProperty .call( traits, sourceId ) ) { return; } const value = Number( traits[sourceId] ); const min = Number( component.dataset .wayfarerHudMin ); const max = Number( component.dataset .wayfarerHudMax ); if ( !Number.isFinite(value) || !Number.isFinite(min) || !Number.isFinite(max) || max <= min ) { return; } const percent = Math.max( 0, Math.min( 100, ( (value - min) / (max - min) ) * 100 ) ); const fill = component.querySelector( '[data-wayfarer-hud-fill]' ); if (fill) { fill.style.width = percent + '%'; } const meter = component.querySelector( '[data-wayfarer-hud-meter]' ); if (meter) { meter.setAttribute( 'aria-valuenow', String(value) ); } const valueNode = component.querySelector( '[data-wayfarer-hud-value]' ); if (valueNode) { valueNode.textContent = String(value); } } ); } function renderRuntimeState(state) { if ( !state || typeof state !== 'object' ) { return; } renderHudState( state ); if ( state.navigation && state.navigation.position !== undefined ) { const position = String( state.navigation.position ); const authorPosition = document.getElementById( 'wayfarer-author-position' ); if (authorPosition) { authorPosition.textContent = position; } } const gameTraits = state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : {}; document .querySelectorAll( '[data-wayfarer-game-trait-value]' ) .forEach( function (cell) { const traitId = cell.dataset .wayfarerGameTraitValue; if ( Object.prototype .hasOwnProperty .call( gameTraits, traitId ) ) { cell.textContent = displayRuntimeValue( gameTraits[traitId] ); } } ); document .querySelectorAll( '[data-wayfarer-region-trait-value]' ) .forEach( function (cell) { const regionId = cell.dataset .wayfarerRegionId; const traitId = cell.dataset .wayfarerRegionTraitValue; const traits = state.regions && state.regions[regionId] && state.regions[regionId].traits && typeof state.regions[regionId].traits === 'object' ? state.regions[regionId].traits : null; if ( traits && Object.prototype .hasOwnProperty .call( traits, traitId ) ) { cell.textContent = displayRuntimeValue( traits[traitId] ); } } ); const debug = document.getElementById( 'wayfarer-runtime-debug' ); if (debug) { debug.textContent = JSON.stringify( state, null, 2 ); } } async function requestTick() { if ( requestInFlight || presentationActive ) { return null; } requestInFlight = true; renderControls(); try { const response = await fetch( config.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ travel_direction: requestedTravelDirection }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Tick endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Tick request failed.' ); } if (tickValue) { tickValue.textContent = String( data.tick ); } renderRuntimeState( data.state ); /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ if ( data.state && data.state.navigation && Object.prototype .hasOwnProperty .call( data.state.navigation, 'direction' ) ) { requestedTravelDirection = Number( data.state.navigation.direction ) < 0 ? -1 : 1; } renderPlayerContext( data.context ); renderTravelStatus( data.travel ); renderStoryletActions( data.actions ); setMessage( 'Tick ' + data.tick + ' saved.' ); if (data.pause === true) { /* * Suppress Action Mode during the brief interval before reload. * The server-rendered presentation will exist after reload. */ presentationActive = true; pauseRuntime( data.pause_reason || 'Travel interrupted.', true ); } return data; } catch (error) { pauseRuntime( error instanceof Error ? error.message : 'Tick request failed.' ); setMessage( error instanceof Error ? error.message : 'Tick request failed.', true ); return null; } finally { requestInFlight = false; renderControls(); if (reloadWhenIdle) { reloadWhenIdle = false; window.location.reload(); } } } async function runAutomaticTick() { if ( !running || presentationActive ) { return; } const result = await requestTick(); if ( !running || presentationActive || result === null ) { return; } timeoutId = window.setTimeout( runAutomaticTick, Number( config.intervalMs ) || 1500 ); } function startRuntime() { if ( running || requestInFlight || presentationActive ) { return; } /* * Travel always leaves inspection/context views and returns the card to * its normal Action view for the next pause. */ setActionView( 'actions' ); running = true; setMessage( 'Automatic advancement started.' ); renderControls(); emitModeEvent(); runAutomaticTick(); } /* * WAYFARER_PRESENTATION_TRANSITION_V63 * * Cover the current DOM before a server-rendered Presentation replaces it. * This prevents the previous Location/Storylet copy from being painted for * a frame during the navigation boundary. */ function beginPresentationTransition() { body.classList.add( 'wayfarer-presentation-transitioning' ); } function pauseRuntime( reason = 'Automatic advancement paused.', reloadForPresentation = false ) { const wasRunning = running; running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } setMessage( reason ); if (wasRunning) { setActionView( 'actions' ); } renderControls(); if (wasRunning) { emitModeEvent(); } if (reloadForPresentation) { /* WAYFARER_PRESENTATION_TRANSITION_V63 */ beginPresentationTransition(); if (requestInFlight) { reloadWhenIdle = true; } else { window.location.reload(); } } } /* * WAYFARER_STORYLET_ACTION_RUNTIME_V59 */ async function requestStorylet( storyletId, entryMode = 'action', parentStoryletId = 0 ) { if ( storyletRequestInFlight || requestInFlight || presentationRequestInFlight ) { return; } storyletId = Number(storyletId || 0); parentStoryletId = Number(parentStoryletId || 0); if (storyletId <= 0) { return; } storyletRequestInFlight = true; renderControls(); try { const response = await fetch( config.storyletEndpoint || 'api/storylet.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, storylet_id: storyletId, entry_mode: entryMode, parent_storylet_id: parentStoryletId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Storylet endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not enter Storylet.' ); } /* * Storylet presentation/Branch markup is server-rendered. Reload * only after the runtime transaction succeeds, so Effects cannot be * repeated by a failed or duplicate browser transition. */ /* WAYFARER_PRESENTATION_TRANSITION_V63 */ beginPresentationTransition(); window.location.reload(); } catch (error) { window.alert( error instanceof Error ? error.message : 'Could not enter Storylet.' ); } finally { storyletRequestInFlight = false; renderControls(); } } async function dismissPresentation() { if ( !presentationActive || !presentationScreen || presentationRequestInFlight ) { return; } presentationRequestInFlight = true; if (presentationContinueButton) { presentationContinueButton.disabled = true; } const presentationType = presentationScreen.dataset .wayfarerPresentationType || ''; const presentationId = Number( presentationScreen.dataset .wayfarerPresentationId || 0 ); try { const response = await fetch( config.presentationEndpoint || 'api/presentation.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, type: presentationType, id: presentationId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Presentation endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not continue.' ); } presentationActive = false; setActionView( 'actions' ); renderRuntimeState( data.state ); renderPlayerContext( data.context ); renderStoryletActions( data.actions ); renderControls(); emitModeEvent(); /* * Place keyboard focus on the first available Action so the * transition works cleanly for keyboard players too. */ const firstAction = actionScreen ? actionScreen.querySelector( 'button:not([disabled]), a[href]' ) : null; if ( firstAction && typeof firstAction.focus === 'function' ) { firstAction.focus(); } } catch (error) { if (presentationContinueButton) { presentationContinueButton.disabled = false; } window.alert( error instanceof Error ? error.message : 'Could not continue.' ); } finally { presentationRequestInFlight = false; /* WAYFARER_PRESENTATION_CONTROL_RESET_V62 * * dismissPresentation() previously refreshed Action controls while * presentationRequestInFlight was still true. Newly-rendered Manual * Storylet buttons were therefore left disabled after a Location or * Storylet presentation was dismissed. Re-render once the request * flag has actually been cleared. */ renderControls(); if ( presentationContinueButton && presentationActive ) { presentationContinueButton.disabled = false; } } } if (onceButton) { onceButton.addEventListener( 'click', async function () { if ( running || presentationActive ) { return; } setMessage(''); await requestTick(); } ); } if (autoButton) { autoButton.addEventListener( 'click', startRuntime ); } if (authorPauseButton) { authorPauseButton.addEventListener( 'click', function () { pauseRuntime( 'Automatic advancement paused.' ); } ); } /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ travelDirectionButtons.forEach( function (button) { button.addEventListener( 'click', function () { const direction = Number( button.dataset .wayfarerTravelDirection ); requestedTravelDirection = direction < 0 ? -1 : 1; startRuntime(); } ); } ); if (continueTravelButton) { continueTravelButton.addEventListener( 'click', startRuntime ); } if (playerPauseButton) { playerPauseButton.addEventListener( 'click', function () { /* * WAYFARER_SMOOTH_MANUAL_PAUSE_V46_2 * * v46.1 keeps Action context current in-place, so manual Pause * no longer needs a full page reload. */ pauseRuntime( 'Travel paused.' ); } ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Ensure V50 detail panels physically belong to the Action card. This is * harmless when the PHP view already placed them correctly, and repairs * any earlier V50 markup that landed just outside the host. */ if (actionScreen) { document .querySelectorAll( '.wayfarer-entity-detail-view' ) .forEach( function (panel) { if ( !actionScreen.contains( panel ) ) { actionScreen.appendChild( panel ); } } ); /* * Delegate navigation from the Action card itself. Any current or * future control using the generic data attributes automatically * participates in the same router. */ actionScreen.addEventListener( 'click', function (event) { const storyletAction = event.target.closest( '[data-wayfarer-storylet-action-id]' ); if ( storyletAction && actionScreen.contains( storyletAction ) ) { if ( running || presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( storyletAction.dataset .wayfarerStoryletActionId, 'action', 0 ); return; } const opener = event.target.closest( '[data-wayfarer-open-action-view]' ); if ( opener && actionScreen.contains( opener ) ) { if ( running || presentationActive ) { return; } event.preventDefault(); setActionView( opener.dataset .wayfarerOpenActionView || 'actions' ); return; } const closer = event.target.closest( '[data-wayfarer-close-action-view]' ); if ( !closer || !actionScreen.contains( closer ) ) { return; } event.preventDefault(); const returnView = closer.dataset .wayfarerReturnActionView || 'actions'; setActionView( returnView ); let focusTarget = null; if (returnView === 'actions') { focusTarget = actionList ? actionList.querySelector( 'button:not([disabled]), a[href]' ) : null; } else { const parentPanel = actionScreen.querySelector( '[data-wayfarer-action-view="' + returnView + '"]' ); focusTarget = parentPanel ? parentPanel.querySelector( '[data-wayfarer-open-action-view], button:not([disabled]), a[href]' ) : null; } if ( focusTarget && typeof focusTarget.focus === 'function' ) { focusTarget.focus(); } } ); } /* WAYFARER_STORYLET_BRANCH_SELECTION_V59 */ document.addEventListener( 'click', function (event) { const branch = event.target.closest( '[data-wayfarer-storylet-branch-id]' ); if (!branch) { return; } if ( !presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( branch.dataset .wayfarerStoryletBranchId, 'branch', branch.dataset .wayfarerStoryletParentId ); } ); if (presentationContinueButton) { presentationContinueButton.addEventListener( 'click', dismissPresentation ); } document.addEventListener( 'keydown', function (event) { if ( !presentationActive || presentationRequestInFlight || storyletRequestInFlight || !presentationContinueButton ) { return; } const target = event.target; if ( target && target.matches && target.matches( 'input, textarea, select, button, a[href], [contenteditable="true"]' ) ) { return; } if ( event.key !== 'Enter' && event.key !== ' ' ) { return; } event.preventDefault(); dismissPresentation(); } ); window.addEventListener( 'pagehide', function () { running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } } ); window.wayfarerRuntimeController = { start: startRuntime, pause: pauseRuntime, dismissPresentation: dismissPresentation, enterStorylet: requestStorylet, isRunning: function () { return running; }, mode: function () { if (presentationActive) { return 'presentation'; } return running ? 'running' : 'paused'; } }; setActionView( 'actions' ); renderControls(); emitModeEvent(); })(); /* |-------------------------------------------------------------------------- | WAYFARER PLAY SHELL v20 |-------------------------------------------------------------------------- | | Reorganizes existing server-rendered nodes into the viewport shell. | Existing nodes are MOVED, not cloned/replaced, so the tick-control | event listeners installed above remain intact. | */ (() => { 'use strict'; if ( document.getElementById( 'wayfarer-play-shell' ) ) { return; } function exactHeading( level, text ) { const headings = document.querySelectorAll( level ); for (const heading of headings) { if ( heading.textContent .replace(/\s+/g, ' ') .trim() === text ) { return heading; } } return null; } function moveRange( start, stopExclusive, destination ) { if ( !start || !destination ) { return; } let node = start; while ( node && node !== stopExclusive ) { const next = node.nextSibling; destination.appendChild( node ); node = next; } } const body = document.body; /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * PHP decides whether Author Mode is permitted. JavaScript only shapes the * interface around that server-side decision. */ const canUseAuthorMode = body.dataset.wayfarerAuthorMode === '1'; body.classList.add( 'wayfarer-play-page' ); const shell = document.createElement( 'div' ); shell.id = 'wayfarer-play-shell'; shell.className = 'wayfarer-play-shell'; const header = document.createElement( 'header' ); header.className = 'wayfarer-play-shell-header'; const title = document.createElement( 'div' ); title.className = 'wayfarer-play-title'; const actions = document.createElement( 'div' ); actions.className = 'wayfarer-shell-actions'; const toggleAuthor = document.createElement( 'button' ); toggleAuthor.type = 'button'; toggleAuthor.className = 'wayfarer-shell-button'; if (canUseAuthorMode) { actions.appendChild( toggleAuthor ); } header.appendChild( title ); if (canUseAuthorMode) { header.appendChild( actions ); } const main = document.createElement( 'main' ); main.className = 'wayfarer-play-main'; const playerPane = document.createElement( 'section' ); playerPane.className = 'wayfarer-player-pane'; const authorPane = document.createElement( 'aside' ); authorPane.className = 'wayfarer-author-pane'; const authorHeader = document.createElement( 'div' ); authorHeader.className = 'wayfarer-author-header'; const authorToolbar = document.createElement( 'div' ); authorToolbar.className = 'wayfarer-author-toolbar'; const tabBar = document.createElement( 'div' ); tabBar.className = 'wayfarer-author-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const authorContent = document.createElement( 'div' ); authorContent.className = 'wayfarer-author-content'; authorPane.appendChild( authorHeader ); authorPane.appendChild( authorToolbar ); authorPane.appendChild( tabBar ); authorPane.appendChild( authorContent ); main.appendChild( playerPane ); if (canUseAuthorMode) { main.appendChild( authorPane ); } else { /* * Keep the player workspace full-width when Author Mode is unavailable. * No Author pane was rendered by PHP and none is attached here. */ shell.classList.add( 'author-collapsed' ); } shell.appendChild( header ); shell.appendChild( main ); body.insertBefore( shell, body.firstChild ); // ------------------------------------------------------------ // Top title/player identity // ------------------------------------------------------------ const pageTitle = Array.from( body.querySelectorAll( ':scope > h1' ) )[0]; if (pageTitle) { const playerIdentity = pageTitle.nextElementSibling; title.appendChild( pageTitle ); if ( playerIdentity && playerIdentity.tagName === 'P' ) { title.appendChild( playerIdentity ); } } // ------------------------------------------------------------ // Player content // ------------------------------------------------------------ /* * WAYFARER_PLAYER_START_SCREEN_V36 * * Before runtime state exists, the start screen owns the full player * workspace and Author Mode is intentionally hidden. */ const startScreen = document.querySelector( '[data-wayfarer-start-screen]' ); if (startScreen) { shell.classList.add( 'is-starting' ); playerPane.appendChild( startScreen ); } const scene = document.getElementById( 'wayfarer-player-scene' ); const gameStateHeading = exactHeading( 'h2', 'Game State' ) || exactHeading( 'h2', 'Game Not Initialized' ); if (gameStateHeading) { const meta = document.createElement( 'div' ); meta.className = 'wayfarer-player-meta'; /* * Move from Game State through the node immediately before the * player scene. This includes initialization messaging/forms but * stops before the actual scene. */ moveRange( gameStateHeading, scene, meta ); playerPane.appendChild( meta ); } if (scene) { playerPane.appendChild( scene ); } /* * WAYFARER_ACTION_SCREEN_V39 * * Action Mode is player-facing and exists independently of Author Mode. */ const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); if (actionScreen) { playerPane.appendChild( actionScreen ); } /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); if (presentationScreen) { playerPane.appendChild( presentationScreen ); } const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); if (travelControls) { playerPane.appendChild( travelControls ); } /* WAYFARER_PLAYER_HUD_V64 */ const persistentHud = document.querySelector( '[data-wayfarer-hud]' ); if (persistentHud) { playerPane.appendChild( persistentHud ); } // ------------------------------------------------------------ // Author header // ------------------------------------------------------------ const authorHeading = exactHeading( 'h2', 'Author Mode' ); if (authorHeading) { const builderLinkBlock = authorHeading.nextElementSibling; authorHeader.appendChild( authorHeading ); if ( builderLinkBlock && builderLinkBlock.tagName === 'P' ) { authorHeader.appendChild( builderLinkBlock ); } } // ------------------------------------------------------------ // Runtime controls // ------------------------------------------------------------ const tickPanel = document.getElementById( 'wayfarer-tick-test' ); if (tickPanel) { authorToolbar.appendChild( tickPanel ); } // ------------------------------------------------------------ // Author tabs // ------------------------------------------------------------ /* WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 */ const panelDefinitions = [ { key: 'state', label: 'State' }, { key: 'debug', label: 'Debug' } ]; function activateTab(key) { document .querySelectorAll( '.wayfarer-author-tab' ) .forEach( function (button) { const active = button.dataset .wayfarerAuthorTab === key; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); authorContent .querySelectorAll( '.wayfarer-author-tab-panel' ) .forEach( function (panel) { panel.classList.toggle( 'is-active', panel.dataset .wayfarerAuthorPanel === key ); } ); try { window.localStorage.setItem( 'wayfarerAuthorTab', key ); } catch (error) { // Storage is optional. } } for (const definition of panelDefinitions) { const panel = document.querySelector( '[data-wayfarer-author-panel="' + definition.key + '"]' ); if (!panel) { continue; } authorContent.appendChild( panel ); const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-author-tab'; button.dataset.wayfarerAuthorTab = definition.key; button.setAttribute( 'role', 'tab' ); button.textContent = definition.label; button.addEventListener( 'click', function () { activateTab( definition.key ); } ); tabBar.appendChild( button ); } let initialTab = 'state'; try { let savedTab = window.localStorage.getItem( 'wayfarerAuthorTab' ); if ( savedTab === 'region' ) { savedTab = 'state'; window.localStorage.setItem( 'wayfarerAuthorTab', 'state' ); } if ( savedTab && authorContent.querySelector( '[data-wayfarer-author-panel="' + savedTab + '"]' ) ) { initialTab = savedTab; } } catch (error) { // Storage is optional. } activateTab( initialTab ); // ------------------------------------------------------------ // Hide / Show Author Mode // WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 // ------------------------------------------------------------ if (canUseAuthorMode) { function renderAuthorToggle() { const collapsed = shell.classList.contains( 'author-collapsed' ); toggleAuthor.textContent = collapsed ? 'Show Author' : 'Hide Author'; toggleAuthor.setAttribute( 'aria-expanded', collapsed ? 'false' : 'true' ); } toggleAuthor.addEventListener( 'click', function () { shell.classList.toggle( 'author-collapsed' ); try { window.localStorage.setItem( 'wayfarerAuthorCollapsed', shell.classList.contains( 'author-collapsed' ) ? '1' : '0' ); } catch (error) { // Storage is optional. } renderAuthorToggle(); } ); try { if ( window.localStorage.getItem( 'wayfarerAuthorCollapsed' ) === '1' ) { shell.classList.add( 'author-collapsed' ); } } catch (error) { // Storage is optional. } renderAuthorToggle(); } else { /* * Player role is not merely a hidden Author interface. Keep the shell * permanently in player-only layout. */ shell.classList.add( 'author-collapsed' ); } // ------------------------------------------------------------ // Remove otherwise-empty top-level HRs left by the old document flow. // ------------------------------------------------------------ body .querySelectorAll( ':scope > hr' ) .forEach( function (rule) { rule.remove(); } ); })(); /* |-------------------------------------------------------------------------- | ENDING RESTART | WAYFARER_ENDING_RESTART_V63 |-------------------------------------------------------------------------- */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; const restartButton = document.querySelector( '[data-wayfarer-restart-game]' ); if ( !config || !restartButton ) { return; } let requestInFlight = false; restartButton.addEventListener( 'click', async function () { if (requestInFlight) { return; } if ( !window.confirm( 'Start this game over? Your current playthrough progress will be erased.' ) ) { return; } requestInFlight = true; restartButton.disabled = true; const originalText = restartButton.textContent; restartButton.textContent = 'Starting over…'; try { const response = await fetch( config.restartEndpoint || 'api/restart.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Restart endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not restart the game.' ); } document.body.classList.add( 'wayfarer-presentation-transitioning' ); window.location.href = 'index.php'; } catch (error) { requestInFlight = false; restartButton.disabled = false; restartButton.textContent = originalText; window.alert( error instanceof Error ? error.message : 'Could not restart the game.' ); } } ); })(); ================================================================================ FILE: _update_backups/v72-20260928-150651/play/includes/action-screen.php ================================================================================ 0) { $actionRegionStmt = $pdo->prepare( " SELECT id, name, navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $actionRegionStmt->execute([ $actionRegionId, (int)$gameId, ]); $actionRegion = $actionRegionStmt->fetch( PDO::FETCH_ASSOC ); if ($actionRegion) { $actionRegionNavigationType = (string)( $actionRegion['navigation_type'] ?? '' ); $actionContextName = trim( (string)( $actionRegion['name'] ?? '' ) ) ?: 'Actions'; } } $actionLocation = wayfarerRuntimeFindCurrentLocation( $pdo, (int)$gameId, $state ); if ($actionLocation) { $locationName = trim( (string)( $actionLocation['name'] ?? '' ) ); if ($locationName !== '') { $actionContextName = $locationName; } } $actionCanTravel = strtolower( trim( $actionRegionNavigationType ) ) === 'linear'; /* WAYFARER_STORYLET_ACTION_SCREEN_V59 */ $actionStorylets = wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$gameId, $state ); /* * WAYFARER_ENTITY_PRESENTATION_V49 * * The V48 card switcher remains generic. V49 supplies the Inventory / Status * view models from canonical runtime Entity state. */ $actionEntityPresentation = wayfarerRuntimeBuildEntityPresentation( $pdo, (int)$gameId, $state, is_array($gameConfig ?? null) ? $gameConfig : [] ); $actionInventory = $actionEntityPresentation['inventory'] ?? []; $actionParty = $actionEntityPresentation['party'] ?? []; /* WAYFARER_PLAYER_TRAITS_STATUS_V52 */ $actionPlayerPresentation = wayfarerRuntimeBuildPlayerPresentation( is_array($gameConfig ?? null) ? $gameConfig : [], $state ); $actionPlayers = $actionPlayerPresentation['players'] ?? []; $actionGameStatusTraits = $actionPlayerPresentation['game_status_traits'] ?? []; /* WAYFARER_TRAVEL_TIME_HUD_V61 */ $actionTravelStatus = wayfarerRuntimeBuildTravelStatus( $pdo, (int)$gameId, $state ); ?>

>

What would you like to do?

No actions are currently available.

================================================================================ FILE: _update_backups/v72-20260928-150651/play/includes/effects.php ================================================================================ true, 'value' => wayfarerNormalizeRuntimeNumber( (float)$effectValue ), 'error' => '', ]; } return [ 'applied' => true, 'value' => $effectValue, 'error' => '', ]; } if ( $operation !== 'add' && $operation !== 'subtract' ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Unsupported operation: ' . $operation, ]; } if ( !is_numeric($currentValue) || !is_numeric($effectValue) ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Add/subtract requires numeric values.', ]; } $current = (float)$currentValue; $amount = (float)$effectValue; $result = $operation === 'add' ? $current + $amount : $current - $amount; return [ 'applied' => true, 'value' => wayfarerNormalizeRuntimeNumber( $result ), 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Tick Effect branch runtime |-------------------------------------------------------------------------- | | A Tick Effect owns one target and an ordered list of result branches. | Conditions are evaluated against the CURRENT runtime state. Because Tick | Effects execute sequentially, a later Tick Effect can see state changes made | by an earlier Tick Effect during the same tick. | */ function wayfarerRuntimeResolveConditionTarget( array $runtimeState, int $currentRegionId, string $scope, string $targetType, string $targetId ): array { $scope = strtolower( trim( $scope ) ); $targetType = strtolower( trim( $targetType ) ); $targetId = trim( $targetId ); // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $targetType === 'trait' && $targetId !== '' ) { $traits = $runtimeState['game']['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // SPECIFIC REGION TRAIT // WAYFARER_SPECIFIC_REGION_CONDITION_SCOPE_V46 // ------------------------------------------------------------ // // Game-level condition editors store a Region-owned Trait as // "region:". This lets a global rule inspect that Region whether // or not the player is currently there. // if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^region:([1-9][0-9]*)$/', $scope, $regionScopeMatch ) === 1 ) { $regionKey = (string)( (int)$regionScopeMatch[1] ); $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $targetType === 'trait' && $targetId !== '' && $currentRegionId > 0 ) { $regionKey = (string)$currentRegionId; $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // PLAYER / LOCATION / ENTITY TRAITS // WAYFARER_RUNTIME_UNIVERSAL_TARGET_RESOLVER_V56 // ------------------------------------------------------------ if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^player:([0-9]+)$/', $scope, $playerScopeMatch ) === 1 ) { $playerKey = (string)((int)$playerScopeMatch[1]); $traits = $runtimeState['players'][$playerKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^location:([1-9][0-9]*)$/', $scope, $locationScopeMatch ) === 1 ) { $locationKey = (string)((int)$locationScopeMatch[1]); $traits = $runtimeState['locations'][$locationKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityScopeMatch ) === 1 ) { $entityKey = (string)((int)$entityScopeMatch[1]); $traits = $runtimeState['entities'][$entityKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $targetType === 'position' && $targetId === 'position' ) { if ( isset( $runtimeState['navigation'] ) && is_array( $runtimeState['navigation'] ) && array_key_exists( 'position', $runtimeState['navigation'] ) ) { return [ 'found' => true, 'value' => $runtimeState['navigation']['position'], ]; } return [ 'found' => false, 'value' => null, ]; } return [ 'found' => false, 'value' => null, ]; } function wayfarerRuntimeTickEffectBranches( array $effect ): array { /* * v28+ canonical structure. * * If the branches key exists, it is authoritative. An empty branches * array therefore means "this effect has no executable results" rather * than silently falling back to legacy compatibility fields. */ if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * Legacy pre-v28 structure. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'conditions', $effect ) ) { $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $effect['operation'] ?? '', 'value_mode' => $effect['value_mode'] ?? 'fixed', ]; if ( strtolower( trim( (string)$branch['value_mode'] ) ) === 'random' ) { $branch['value_min'] = $effect['value_min'] ?? 0; $branch['value_max'] = $effect['value_max'] ?? 0; } else { $branch['value'] = $effect['value'] ?? 0; } return [ $branch ]; } return []; } function wayfarerRuntimeSelectTickEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeTickEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Tick Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; /* * Validate before evaluating. Invalid authored data fails closed and * does NOT fall through to OTHERWISE, because doing so could silently * produce the wrong gameplay result. */ $validation = wayfarerConditionValidateNode( $conditions ); if (!$validation['valid']) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Tick Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Runtime Trait bounds |-------------------------------------------------------------------------- | | WAYFARER_RUNTIME_TRAIT_BOUNDS_V30 | | Authored numeric Trait min/max values are runtime invariants. | State is normalized before condition evaluation and every subsequent | mutation is clamped again before it is written. | */ function wayfarerRuntimeDecodeTraitConfig( $rawConfig ): array { if (is_array($rawConfig)) { return $rawConfig; } if ( $rawConfig === null || $rawConfig === '' ) { return []; } $decoded = json_decode( (string)$rawConfig, true ); return is_array($decoded) ? $decoded : []; } function wayfarerRuntimeIndexTraitDefinitions( array $traits ): array { $definitions = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $definitions[$traitId] = $trait; } return $definitions; } function wayfarerRuntimeLoadTickTraitDefinitions( PDO $pdo, int $gameId, array $regionConfig ): array { $gameConfig = []; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawGameConfig = $stmt->fetchColumn(); if ($rawGameConfig !== false) { $gameConfig = wayfarerRuntimeDecodeTraitConfig( $rawGameConfig ); } } /* WAYFARER_RUNTIME_UNIVERSAL_DEFINITIONS_V56 */ $definitions = [ 'game' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['traits'] ?? null ) ? $gameConfig['traits'] : [] ), 'player' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['player_traits'] ?? null ) ? $gameConfig['player_traits'] : [] ), 'region' => wayfarerRuntimeIndexTraitDefinitions( is_array( $regionConfig['traits'] ?? null ) ? $regionConfig['traits'] : [] ), 'regions' => [], 'locations' => [], 'entities' => [], ]; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $definitions['regions'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } $stmt = $pdo->prepare( " SELECT l.id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $definitions['locations'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } $stmt = $pdo->prepare( " SELECT id, entity_type, config FROM wf_entities WHERE game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); if ( function_exists( 'wayfarerEntityActiveTraitDefinitions' ) ) { $definitions['entities'][$id] = wayfarerEntityActiveTraitDefinitions( $config, (string)( $row['entity_type'] ?? '' ) ); } else { $definitions['entities'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } } } return $definitions; } function wayfarerRuntimeClampValueToTraitDefinition( $value, ?array $definition ): array { $result = [ 'valid' => true, 'value' => $value, 'clamped' => false, 'min' => null, 'max' => null, 'error' => '', ]; /* * Bounds currently belong only to authored Number Traits. */ if ( !$definition || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' ) { return $result; } if (!is_numeric($value)) { $result['valid'] = false; $result['error'] = 'Numeric Trait runtime state must be numeric.'; return $result; } $min = isset($definition['min']) && $definition['min'] !== '' && is_numeric($definition['min']) ? (float)$definition['min'] : null; $max = isset($definition['max']) && $definition['max'] !== '' && is_numeric($definition['max']) ? (float)$definition['max'] : null; $numeric = (float)$value; $bounded = $numeric; if ( $min !== null && $bounded < $min ) { $bounded = $min; } if ( $max !== null && $bounded > $max ) { $bounded = $max; } $result['value'] = wayfarerNormalizeRuntimeNumber( $bounded ); $result['clamped'] = $bounded !== $numeric; $result['min'] = $min === null ? null : wayfarerNormalizeRuntimeNumber( $min ); $result['max'] = $max === null ? null : wayfarerNormalizeRuntimeNumber( $max ); return $result; } function wayfarerRuntimeApplyBoundedTraitOperation( $storedBefore, string $operation, $effectValue, ?array $definition ): array { /* * Repair an already-illegal persisted value BEFORE applying the new * operation. Example: 120 with max 100 and subtract 10 becomes * 100 - 10 = 90, not 120 - 10 = 110 -> 100. */ $beforeBound = wayfarerRuntimeClampValueToTraitDefinition( $storedBefore, $definition ); if (!$beforeBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $storedBefore, 'requested_after' => $storedBefore, 'after' => $storedBefore, 'clamped' => false, 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $beforeBound['error'], ]; } $normalizedBefore = $beforeBound['value']; $operationResult = wayfarerApplyRuntimeOperation( $normalizedBefore, $operation, $effectValue ); if (!$operationResult['applied']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $normalizedBefore, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $operationResult['error'], ]; } $requestedAfter = $operationResult['value']; $afterBound = wayfarerRuntimeClampValueToTraitDefinition( $requestedAfter, $definition ); if (!$afterBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $afterBound['error'], ]; } return [ 'applied' => true, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $afterBound['value'], 'clamped' => $beforeBound['clamped'] || $afterBound['clamped'], 'min' => $afterBound['min'], 'max' => $afterBound['max'], 'error' => '', ]; } function wayfarerRuntimeClampApplicableTraitState( array &$runtimeState, array $traitDefinitions, int $currentRegionId ): array { $repairs = []; // ------------------------------------------------------------ // GAME TRAITS // ------------------------------------------------------------ $gameTraits = $runtimeState['game']['traits'] ?? null; if (is_array($gameTraits)) { foreach ( $traitDefinitions['game'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $gameTraits ) ) { continue; } $before = $runtimeState['game']['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['game']['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } // ------------------------------------------------------------ // CURRENT REGION TRAITS // ------------------------------------------------------------ $regionKey = (string)$currentRegionId; $regionTraits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if (is_array($regionTraits)) { foreach ( $traitDefinitions['region'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $regionTraits ) ) { continue; } $before = $runtimeState['regions'][$regionKey]['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } return $repairs; } function wayfarerApplyRuntimeTickEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectTickEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection['branch_index'], 'applied' => false, 'error' => $selection['error'], ]; } /* * No conditional branch matched and there was no OTHERWISE. * This is a valid "do nothing this tick" result. */ if (!$selection['matched']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection['branch_index']; $operation = strtolower( trim( (string)( $branch['operation'] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; /* * Explicit No Change: * the branch DID match, so this Tick Effect stops here, but state does * not mutate. */ if ($operation === 'none') { $current = wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $type, $targetId ); return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'amount' => null, 'before' => $current['found'] ? $current['value'] : null, 'after' => $current['found'] ? $current['value'] : null, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'applied' => false, 'no_change' => true, 'error' => '', ]; } /* WAYFARER_TICK_EFFECT_RANDOM_RUNTIME_V58 */ $resolvedValue = wayfarerRuntimeRollEffectValue( $branch ); if (!$resolvedValue['valid']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'applied' => false, 'error' => $resolvedValue['error'], ]; } /* * WAYFARER_BIDIRECTIONAL_TRAVEL_TICK_EFFECT_V71 * * Tick movement is relative to the player's current journey direction. * Only Tick Effects are reversed here. One-time narrative Effects remain * literal state changes, so an authored "Subtract 3 position" effect does * not unexpectedly change meaning because the player is traveling back. */ if ( $scope === 'navigation' && $type === 'position' && $targetId === 'position' && wayfarerRuntimeNavigationDirection( $runtimeState ) < 0 ) { if ($operation === 'add') { $operation = 'subtract'; } elseif ($operation === 'subtract') { $operation = 'add'; } } /* * Apply ONLY the selected result branch. The legacy effect-level * operation/value fields are deliberately ignored when branches exist. */ $resolvedEffect = $effect; $resolvedEffect['operation'] = $operation; $resolvedEffect['value'] = $resolvedValue['value']; $report = wayfarerApplyRuntimeEffect( $runtimeState, $resolvedEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; $report['value_mode'] = $resolvedValue['mode']; $report['rolled_value'] = $resolvedValue['value']; if ($resolvedValue['mode'] === 'random') { $report['random_min'] = $resolvedValue['min']; $report['random_max'] = $resolvedValue['max']; } return $report; } function wayfarerApplyRuntimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); $operation = strtolower( trim( (string)( $effect['operation'] ?? '' ) ) ); $effectValue = $effect['value'] ?? 0; // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $type === 'position' && $targetId === 'position' ) { if ( !isset($runtimeState['navigation']) || !is_array($runtimeState['navigation']) ) { $runtimeState['navigation'] = []; } $before = $runtimeState['navigation']['position'] ?? 0; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); $after = $before; if ( $result['applied'] && !is_numeric($result['value']) ) { $result['applied'] = false; $result['error'] = 'Navigation position must be numeric.'; } if ($result['applied']) { /* * Global navigation rule: * position may never be less than zero. */ $after = max( 0, $result['value'] ); $runtimeState['navigation']['position'] = $after; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $after, 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['game']) || !is_array($runtimeState['game']) ) { $runtimeState['game'] = []; } if ( !isset($runtimeState['game']['traits']) || !is_array($runtimeState['game']['traits']) ) { $runtimeState['game']['traits'] = []; } $before = $runtimeState['game']['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['game'][$targetId] ) && is_array( $traitDefinitions['game'][$targetId] ) ? $traitDefinitions['game'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); /* * Persist the normalized value even when a malformed operation fails * after an existing illegal value was repaired. */ if ( $result['normalized_before'] !== $before ) { $runtimeState['game']['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['game']['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // ITEM ENTITY QUANTITY // WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 // ------------------------------------------------------------ if ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityScopeMatch ) === 1 && ( (string)( $effect['target_system_key'] ?? '' ) === 'quantity' || $targetId === 'sys_item_quantity' ) ) { $entityKey = (string)( (int)$entityScopeMatch[1] ); if ( !isset( $runtimeState['entities'][$entityKey] ) || !is_array( $runtimeState['entities'][$entityKey] ) || !isset( $runtimeState['entities'][$entityKey]['traits'] ) || !is_array( $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Entity runtime state was not initialized.', ]; } if ( !array_key_exists( $targetId, $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Quantity runtime state was not found.', ]; } $before = $runtimeState['entities'][$entityKey]['traits'][$targetId]; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); if ( !$result['applied'] || !is_numeric( $result['value'] ?? null ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $before, 'applied' => false, 'error' => $result['error'] !== '' ? $result['error'] : 'Item Quantity must remain numeric.', ]; } $requestedAfter = wayfarerNormalizeRuntimeNumber( (float)$result['value'] ); /* * Quantity is globally non-negative. */ $after = wayfarerNormalizeRuntimeNumber( max( 0.0, (float)$requestedAfter ) ); $runtimeState['entities'][$entityKey]['traits'][$targetId] = $after; /* * Discovery is persistent. Receiving any positive starting quantity * discovers the Item; setting it back to zero never undiscoveres it. */ if ( (float)$after > 0 ) { $runtimeState['entities'][$entityKey]['discovered'] = true; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_system_key' => 'quantity', 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'requested_after' => $requestedAfter, 'after' => $after, 'clamped' => (float)$after !== (float)$requestedAfter, 'min' => 0, 'max' => null, 'applied' => true, 'error' => '', ]; } // ------------------------------------------------------------ // SPECIFIC PLAYER / REGION / LOCATION / ENTITY TRAITS // WAYFARER_RUNTIME_UNIVERSAL_EFFECT_TARGETS_V56 // ------------------------------------------------------------ $stateBucket = null; $stateKey = null; $definition = null; if ( $type === 'trait' && $targetId !== '' && preg_match( '/^player:([0-9]+)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'players'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['player'][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^region:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'regions'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['regions'][$stateKey][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^location:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'locations'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['locations'][$stateKey][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 && $targetId !== 'sys_item_quantity' ) { $stateBucket = 'entities'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['entities'][$stateKey][$targetId] ?? null; } if ( $stateBucket !== null && $stateKey !== null ) { if ( !isset($runtimeState[$stateBucket][$stateKey]) || !is_array($runtimeState[$stateBucket][$stateKey]) || !isset($runtimeState[$stateBucket][$stateKey]['traits']) || !is_array($runtimeState[$stateBucket][$stateKey]['traits']) || !array_key_exists( $targetId, $runtimeState[$stateBucket][$stateKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Runtime Trait target was not found.', ]; } $before = $runtimeState[$stateBucket][$stateKey]['traits'][$targetId]; /* * Party Membership is mutable runtime state, but still a binary * Wayfarer system property. The 0/1 rule is enforced internally and is * never exposed as a separate Target/max metadata value. */ if ( $stateBucket === 'entities' && $targetId === 'sys_character_party_member' ) { $operationResult = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); if ( !$operationResult['applied'] || !is_numeric( $operationResult['value'] ?? null ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $before, 'applied' => false, 'error' => $operationResult['error'] !== '' ? $operationResult['error'] : 'Party Membership requires a numeric 0/1 result.', ]; } $after = (float)$operationResult['value'] > 0 ? 1 : 0; $runtimeState[$stateBucket][$stateKey]['traits'][$targetId] = $after; return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_system_key' => 'party_member', 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $after, 'applied' => true, 'error' => '', ]; } $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, is_array($definition) ? $definition : null ); if ($result['applied']) { $runtimeState[$stateBucket][$stateKey]['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // PLAYER TRAIT // WAYFARER_STARTING_CHOICE_PLAYER_TARGETS_V53 // ------------------------------------------------------------ if ( $scope === 'player' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['players']) || !is_array($runtimeState['players']) ) { $runtimeState['players'] = []; } $selector = strtolower( trim( (string)( $effect['target_selector'] ?? '' ) ) ); $targetKeys = []; if ($selector === 'all') { foreach ( $runtimeState['players'] as $playerKey => $playerState ) { if (is_array($playerState)) { $targetKeys[] = (string)$playerKey; } } } elseif ( $selector === 'slot' && array_key_exists( 'target_slot', $effect ) ) { $slot = max( 0, (int)$effect['target_slot'] ); $targetKeys[] = (string)$slot; } else { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect requires an All Players or Player Slot target.', ]; } if (empty($targetKeys)) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect did not resolve any Player Characters.', ]; } $definition = isset( $traitDefinitions['player'][$targetId] ) && is_array( $traitDefinitions['player'][$targetId] ) ? $traitDefinitions['player'][$targetId] : null; $results = []; $firstError = ''; foreach ($targetKeys as $playerKey) { if ( !isset( $runtimeState['players'][$playerKey] ) || !is_array( $runtimeState['players'][$playerKey] ) ) { $firstError = 'Player Character slot ' . ((int)$playerKey + 1) . ' does not exist in runtime state.'; $results[$playerKey] = [ 'applied' => false, 'error' => $firstError, ]; break; } if ( !isset( $runtimeState['players'][$playerKey]['traits'] ) || !is_array( $runtimeState['players'][$playerKey]['traits'] ) ) { $runtimeState['players'][$playerKey]['traits'] = []; } $before = $runtimeState['players'][$playerKey]['traits'][$targetId] ?? 0; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['after']; } $results[$playerKey] = [ 'slot' => (int)$playerKey, 'name' => (string)( $runtimeState['players'][$playerKey]['name'] ?? ( 'Player ' . ((int)$playerKey + 1) ) ), 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; if ( !$result['applied'] || $result['error'] !== '' ) { $firstError = $result['error'] !== '' ? $result['error'] : 'Player Trait effect could not be applied.'; break; } } $allApplied = $firstError === ''; return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_selector' => $selector, 'target_slot' => $selector === 'slot' ? (int)( $effect['target_slot'] ?? 0 ) : null, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'results' => $results, 'applied' => $allApplied, 'error' => $firstError, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $type === 'trait' && $targetId !== '' ) { $regionKey = (string)$currentRegionId; if ( !isset($runtimeState['regions']) || !is_array($runtimeState['regions']) ) { $runtimeState['regions'] = []; } if ( !isset($runtimeState['regions'][$regionKey]) || !is_array($runtimeState['regions'][$regionKey]) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset($runtimeState['regions'][$regionKey]['traits']) || !is_array($runtimeState['regions'][$regionKey]['traits']) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } $before = $runtimeState['regions'][$regionKey]['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['region'][$targetId] ) && is_array( $traitDefinitions['region'][$targetId] ) ? $traitDefinitions['region'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Unsupported runtime target.', ]; } /* |-------------------------------------------------------------------------- | Game Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_GAME_TICK_EFFECT_RUNTIME_V33 | | Game Tick Effects execute once on every successful runtime tick before | the active Region's Tick Effects. This creates a deterministic layering: | | Game rules -> Region rules | */ function wayfarerApplyGameTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { if ($gameId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active game is available for Game Tick Effects.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { $reports = [ [ 'applied' => false, 'error' => 'Game ' . $gameId . ' was not found.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $gameConfig = []; if ( $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, [] ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $gameEffects = $gameConfig['tick_effects'] ?? []; if (!is_array($gameEffects)) { $gameEffects = []; } $reports = []; foreach ($gameEffects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Game Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; $runtimeState['runtime']['last_game_trait_bound_repairs'] = $boundRepairs; return $reports; } /* |-------------------------------------------------------------------------- | Generalized one-time Effect runtime | WAYFARER_GENERALIZED_EFFECT_RUNTIME_V43 |-------------------------------------------------------------------------- | | Conditions use the same shared evaluator as Tick Effect branches. | Random values are resolved exactly once, immediately before the selected | result mutates runtime state. | */ function wayfarerRuntimeEffectBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * v42 compatibility: one Effect was one unconditional operation/value. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $branch = [ 'conditions' => null, 'operation' => $effect['operation'] ?? 'add', 'value_mode' => $effect['value_mode'] ?? 'fixed', ]; if ( strtolower( trim( (string)( $branch[ 'value_mode' ] ) ) ) === 'random' ) { $branch['value_min'] = $effect['value_min'] ?? 0; $branch['value_max'] = $effect['value_max'] ?? 0; } else { $branch['value'] = $effect['value'] ?? 0; } return [ $branch ]; } return []; } function wayfarerRuntimeSelectEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; $validation = wayfarerConditionValidateNode( $conditions ); if ( !$validation['valid'] ) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } function wayfarerRuntimeRollEffectValue( array $branch ): array { $mode = strtolower( trim( (string)( $branch[ 'value_mode' ] ?? 'fixed' ) ) ); if ($mode !== 'random') { $value = $branch['value'] ?? null; /* WAYFARER_FIXED_SCALAR_EFFECT_RUNTIME_V58 */ return [ 'valid' => true, 'mode' => 'fixed', 'value' => is_numeric($value) ? wayfarerNormalizeRuntimeNumber( (float)$value ) : $value, 'min' => null, 'max' => null, 'error' => '', ]; } $minimum = $branch['value_min'] ?? null; $maximum = $branch['value_max'] ?? null; if ( !is_numeric($minimum) || !is_numeric($maximum) ) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => $minimum, 'max' => $maximum, 'error' => 'Random Effect requires numeric Min and Max values.', ]; } $min = (float)$minimum; $max = (float)$maximum; if ($min > $max) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => 'Random Effect Min cannot be greater than Max.', ]; } if ( floor($min) === $min && floor($max) === $max ) { $rolled = random_int( (int)$min, (int)$max ); } else { /* * Continuous decimal range. Six decimal places of entropy are more * than sufficient for authored simulation values and preserve generic * numeric Min/Max support without requiring authored precision metadata. */ $fraction = random_int( 0, 1000000 ) / 1000000; $rolled = $min + (($max - $min) * $fraction); } return [ 'valid' => true, 'mode' => 'random', 'value' => wayfarerNormalizeRuntimeNumber( (float)$rolled ), 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => '', ]; } function wayfarerApplyRuntimeOneTimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect[ 'target_scope' ] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect[ 'target_type' ] ?? '' ) ) ); $targetId = trim( (string)( $effect[ 'target_id' ] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection[ 'branch_index' ], 'applied' => false, 'error' => $selection[ 'error' ], ]; } if ( !$selection['matched'] ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection[ 'branch_index' ]; $operation = strtolower( trim( (string)( $branch[ 'operation' ] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; if ($operation === 'none') { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => null, 'rolled_value' => null, 'applied' => false, 'error' => '', ]; } $resolved = wayfarerRuntimeRollEffectValue( $branch ); if (!$resolved['valid']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => $resolved[ 'mode' ], 'rolled_value' => null, 'random_min' => $resolved[ 'min' ], 'random_max' => $resolved[ 'max' ], 'applied' => false, 'error' => $resolved[ 'error' ], ]; } $concreteEffect = [ 'target_scope' => $scope, 'target_type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'value' => $resolved[ 'value' ], ]; $report = wayfarerApplyRuntimeEffect( $runtimeState, $concreteEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; $report['value_mode'] = $resolved[ 'mode' ]; $report['rolled_value'] = $resolved[ 'value' ]; if ( $resolved['mode'] === 'random' ) { $report['random_min'] = $resolved[ 'min' ]; $report['random_max'] = $resolved[ 'max' ]; } return $report; } /* |-------------------------------------------------------------------------- | One-time Effect player feedback | WAYFARER_ONE_TIME_EFFECT_FEEDBACK_V70 |-------------------------------------------------------------------------- | | Change events are derived from the Effect report AFTER runtime mutation. | Therefore Random rolls and min/max/non-negative clamps are already reflected | in before/after, and the player sees the quantity that truly changed. | | This layer is intentionally called only by narrative one-time Effect paths. | Tick Effects remain silent. | */ function wayfarerRuntimeFeedbackFindTrait( array $config, string $targetId, string $collection = 'traits' ): ?array { $traits = isset($config[$collection]) && is_array($config[$collection]) ? $config[$collection] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } $id = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($id === $targetId) { return $trait; } } return null; } function wayfarerRuntimeFeedbackTraitIsPlayerFacing(array $trait): bool { $feedback = strtolower( trim( (string)( $trait['feedback'] ?? '' ) ) ); if ($feedback === 'hide') { return false; } if ($feedback === 'show') { return true; } $display = isset($trait['display']) && is_array($trait['display']) ? $trait['display'] : []; $surfaces = isset($display['surfaces']) && is_array($display['surfaces']) ? $display['surfaces'] : []; return !empty($surfaces); } function wayfarerRuntimeFeedbackTraitMetadata( array $trait, string $fallbackLabel, string $ownerLabel = '' ): ?array { if (!wayfarerRuntimeFeedbackTraitIsPlayerFacing($trait)) { return null; } $label = trim( (string)( $trait['name'] ?? $fallbackLabel ) ); if ($label === '') { $label = $fallbackLabel; } $type = strtolower( trim( (string)( $trait['type'] ?? 'number' ) ) ); if ($type === 'binary') { $type = 'boolean'; } return [ 'kind' => 'trait', 'label' => $label, 'owner_label' => $ownerLabel, 'data_type' => $type, ]; } function wayfarerRuntimeFeedbackTargetMetadata( PDO $pdo, int $gameId, array $runtimeState, array $report ): ?array { $scope = strtolower( trim( (string)( $report['scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $report['type'] ?? '' ) ) ); $targetId = trim( (string)( $report['target_id'] ?? '' ) ); if ( $scope === '' || $targetId === '' || $type !== 'trait' ) { return null; } /* Navigation is deliberately silent even for one-time Effects. */ if ($scope === 'navigation') { return null; } /* -------------------------------------------------------------- * Entity state: Quantity and Party Membership are intrinsically visible. * ----------------------------------------------------------- */ if ( preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityMatch ) === 1 ) { $entityId = (int)$entityMatch[1]; $stmt = $pdo->prepare( " SELECT name, entity_type, config FROM wf_entities WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $entityId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $entityName = trim( (string)( $row['name'] ?? ('Entity ' . $entityId) ) ); if ($entityName === '') { $entityName = 'Entity ' . $entityId; } $systemKey = strtolower( trim( (string)( $report['target_system_key'] ?? '' ) ) ); if ( $targetId === 'sys_item_quantity' || $systemKey === 'quantity' ) { return [ 'kind' => 'item_quantity', 'label' => $entityName, 'owner_label' => $entityName, 'data_type' => 'number', ]; } if ( $targetId === 'sys_character_party_member' || $systemKey === 'party_member' ) { return [ 'kind' => 'party_membership', 'label' => $entityName, 'owner_label' => $entityName, 'data_type' => 'boolean', ]; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, $entityName ) : null; } /* -------------------------------------------------------------- * Game Trait. * ----------------------------------------------------------- */ if ($scope === 'game') { $stmt = $pdo->prepare( 'SELECT config FROM wf_games WHERE id = ? LIMIT 1' ); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); if ($raw === false) { return null; } $config = wayfarerRuntimeDecodeTraitConfig($raw); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, 'Game' ) : null; } /* -------------------------------------------------------------- * Specific Region Trait, plus legacy current-Region compatibility. * ----------------------------------------------------------- */ $regionId = 0; if ( preg_match( '/^region:([1-9][0-9]*)$/', $scope, $regionMatch ) === 1 ) { $regionId = (int)$regionMatch[1]; } elseif ($scope === 'region') { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); } if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, trim((string)($row['name'] ?? 'Region')) ) : null; } /* -------------------------------------------------------------- * Location Trait. * ----------------------------------------------------------- */ if ( preg_match( '/^location:([1-9][0-9]*)$/', $scope, $locationMatch ) === 1 ) { $locationId = (int)$locationMatch[1]; $stmt = $pdo->prepare( " SELECT l.name, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE l.id = ? AND r.game_id = ? LIMIT 1 " ); $stmt->execute([ $locationId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, trim((string)($row['name'] ?? 'Location')) ) : null; } /* -------------------------------------------------------------- * Specific Player Character Trait. * ----------------------------------------------------------- */ if ( preg_match( '/^player:([0-9]+)$/', $scope, $playerMatch ) === 1 ) { $slot = (int)$playerMatch[1]; $stmt = $pdo->prepare( 'SELECT config FROM wf_games WHERE id = ? LIMIT 1' ); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); if ($raw === false) { return null; } $config = wayfarerRuntimeDecodeTraitConfig($raw); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId, 'player_traits' ); if (!is_array($trait)) { return null; } $playerName = trim( (string)( $runtimeState['players'][(string)$slot]['name'] ?? ('Player ' . ($slot + 1)) ) ); return wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, $playerName ); } return null; } function wayfarerRuntimeBuildEffectChangeEvents( PDO $pdo, int $gameId, array $runtimeState, array $reports ): array { $changes = []; foreach ($reports as $report) { if ( !is_array($report) || empty($report['applied']) ) { continue; } $metadata = wayfarerRuntimeFeedbackTargetMetadata( $pdo, $gameId, $runtimeState, $report ); if (!is_array($metadata)) { continue; } $before = $report['before'] ?? null; $after = $report['after'] ?? null; $kind = (string)($metadata['kind'] ?? 'trait'); $label = trim((string)($metadata['label'] ?? '')); if ($label === '') { continue; } if ($kind === 'party_membership') { if (!is_numeric($before) || !is_numeric($after)) { continue; } $beforeMember = (float)$before > 0; $afterMember = (float)$after > 0; if ($beforeMember === $afterMember) { continue; } $joined = $afterMember; $changes[] = [ 'kind' => 'party_membership', 'direction' => $joined ? 'gain' : 'loss', 'label' => $label, 'before' => $beforeMember ? 1 : 0, 'after' => $afterMember ? 1 : 0, 'delta' => $joined ? 1 : -1, 'text' => $label . ($joined ? ' joined your party.' : ' left your party.'), 'aria_text' => $label . ($joined ? ' joined your party.' : ' left your party.'), ]; continue; } $dataType = strtolower( trim((string)($metadata['data_type'] ?? 'number')) ); if ($dataType === 'boolean') { if (!is_numeric($before) || !is_numeric($after)) { continue; } $beforeBool = (float)$before > 0; $afterBool = (float)$after > 0; if ($beforeBool === $afterBool) { continue; } $changes[] = [ 'kind' => $kind, 'direction' => $afterBool ? 'gain' : 'loss', 'label' => $label, 'before' => $beforeBool ? 1 : 0, 'after' => $afterBool ? 1 : 0, 'delta' => $afterBool ? 1 : -1, 'text' => $label . ': ' . ($afterBool ? 'On' : 'Off'), 'aria_text' => $label . ' is now ' . ($afterBool ? 'on.' : 'off.'), ]; continue; } if (!is_numeric($before) || !is_numeric($after)) { continue; } $deltaFloat = (float)$after - (float)$before; if (abs($deltaFloat) < 0.000000000001) { continue; } $delta = wayfarerNormalizeRuntimeNumber($deltaFloat); $amount = wayfarerNormalizeRuntimeNumber(abs($deltaFloat)); $gain = $deltaFloat > 0; $text = ($gain ? '+' : '-') . (string)$amount . ' ' . $label; if ($kind === 'item_quantity') { $ariaText = ($gain ? 'Gained ' : 'Lost ') . (string)$amount . ' ' . $label . '.'; } else { $ariaText = $label . ($gain ? ' increased by ' : ' decreased by ') . (string)$amount . '.'; } $changes[] = [ 'kind' => $kind, 'direction' => $gain ? 'gain' : 'loss', 'label' => $label, 'before' => $before, 'after' => $after, 'delta' => $delta, 'text' => $text, 'aria_text' => $ariaText, ]; } return $changes; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECT_RUNTIME_V42 | | These are event Effects, not Tick Effects. The caller decides that a | Location has been triggered and invokes this function exactly once for that | trigger. Effects execute top-to-bottom against current runtime state. | */ function wayfarerApplyLocationEffects( PDO $pdo, int $gameId, array $location, array &$runtimeState ): array { $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeTraitConfig( $location['config'] ?? null ); $effects = $locationConfig['effects'] ?? []; if (!is_array($effects)) { $effects = []; } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false ) { $regionConfig = wayfarerRuntimeDecodeTraitConfig( $rawRegionConfig ); } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeOneTimeEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_location_effects'] = [ 'location_id' => $locationId, 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), 'reports' => $reports, /* WAYFARER_LOCATION_EFFECT_FEEDBACK_V70 */ 'changes' => wayfarerRuntimeBuildEffectChangeEvents( $pdo, $gameId, $runtimeState, $reports ), 'bound_repairs' => $boundRepairs, ]; return $reports; } /* |-------------------------------------------------------------------------- | Current Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECT_RUNTIME_V35 | | Linear Regions resolve a current Location by exact authored Position, | matching the player-scene resolver. Location Tick Effects run after Region | Tick Effects, so movement performed by the Region can cause arrival effects | at the newly reached Location on the same tick. | */ function wayfarerRuntimeFindCurrentLocation( PDO $pdo, int $gameId, array $runtimeState ): ?array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ( $gameId <= 0 || $regionId <= 0 ) { return null; } $position = $runtimeState['navigation']['position'] ?? null; if (!is_numeric($position)) { return null; } $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($locations as $location) { $config = []; if ( !empty( $location['config'] ) ) { $decoded = json_decode( (string)$location['config'], true ); if (is_array($decoded)) { $config = $decoded; } } if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } if ( (float)$config['position'] === (float)$position ) { $location['_runtime_config'] = $config; /* * WAYFARER_LOCATION_CONDITIONS_CURRENT_V45 */ if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } return $location; } } return null; } function wayfarerApplyCurrentLocationTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } /* * Being between authored Locations is normal. Record an empty result * rather than treating the absence of a Location as an error. */ if (!$location) { $runtimeState['runtime']['last_location_id'] = null; $runtimeState['runtime']['last_location_tick_effects'] = []; return []; } $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : []; $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); /* * Location effects may target Current Region Traits, so load the active * Region's Trait definitions rather than the Location's authored Traits. */ $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false && $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $locationEffects = $locationConfig['tick_effects'] ?? []; if (!is_array($locationEffects)) { $locationEffects = []; } $reports = []; foreach ( $locationEffects as $effect ) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } $runtimeState['runtime']['last_location_id'] = $locationId; $runtimeState['runtime']['last_location_tick_effects'] = $reports; $runtimeState['runtime']['last_location_trait_bound_repairs'] = $boundRepairs; return $reports; } function wayfarerApplyCurrentRegionTickEffects( PDO $pdo, array &$runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ($regionId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active navigation region.', ] ]; /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Reports are returned for the current request, not persisted. */ return $reports; } $stmt = $pdo->prepare( " SELECT id, game_id, config FROM wf_regions WHERE id = ? LIMIT 1 " ); $stmt->execute([ $regionId ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { $reports = [ [ 'applied' => false, 'error' => 'Active Region ' . $regionId . ' was not found.', ] ]; /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Reports are returned for the current request, not persisted. */ return $reports; } $config = []; if (!empty($region['config'])) { $decoded = json_decode( $region['config'], true ); if (is_array($decoded)) { $config = $decoded; } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, (int)( $region['game_id'] ?? 0 ), $config ); /* * Enforce the invariant before any branch condition is evaluated. * This also repairs state produced by older runtime versions. */ $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $regionId ); $effects = $config['tick_effects'] ?? []; if (!is_array($effects)) { $effects = []; } $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed tick effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $regionId, $traitDefinitions ); } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Tick reports and bound-repair reports are immediate diagnostics only. */ return $reports; } ================================================================================ FILE: _update_backups/v72-20260928-150651/play/includes/runtime.php ================================================================================ $mode, 'starting_day' => max(1, (int)($raw['starting_day'] ?? 1)), 'ticks_per_day' => max(1, (int)($raw['ticks_per_day'] ?? 1)), 'starting_hour' => max(0, (int)($raw['starting_hour'] ?? 8)), 'ticks_per_hour' => max(1, (int)($raw['ticks_per_hour'] ?? 1)), 'hours_per_day' => max(1, (int)($raw['hours_per_day'] ?? 24)), ]; if ($config['starting_hour'] >= $config['hours_per_day']) { $config['starting_hour'] = $config['hours_per_day'] - 1; } return $config; } function wayfarerRuntimeInitializeClockFromConfig( array $gameConfig, array &$runtimeState ): void { if (!isset($runtimeState['runtime']) || !is_array($runtimeState['runtime'])) { $runtimeState['runtime'] = []; } $config = wayfarerRuntimeTimeConfig($gameConfig); if ($config['mode'] === 'none') { unset($runtimeState['runtime']['clock']); return; } $clock = [ 'mode' => $config['mode'], 'day' => $config['starting_day'], ]; if ($config['mode'] === 'days') { $clock['tick_in_day'] = 0; } else { $clock['hour'] = $config['starting_hour']; $clock['tick_in_hour'] = 0; } $runtimeState['runtime']['clock'] = $clock; } function wayfarerRuntimeLoadGameConfig(PDO $pdo, int $gameId): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare('SELECT config FROM wf_games WHERE id = ? LIMIT 1'); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); return wayfarerRuntimeDecodeConfig($raw === false ? null : $raw); } function wayfarerRuntimeEnsureClock( PDO $pdo, int $gameId, array &$runtimeState ): array { $gameConfig = wayfarerRuntimeLoadGameConfig($pdo, $gameId); $timeConfig = wayfarerRuntimeTimeConfig($gameConfig); $clock = $runtimeState['runtime']['clock'] ?? null; if ($timeConfig['mode'] === 'none') { unset($runtimeState['runtime']['clock']); return $timeConfig; } if (!is_array($clock) || (string)($clock['mode'] ?? '') !== $timeConfig['mode']) { wayfarerRuntimeInitializeClockFromConfig($gameConfig, $runtimeState); } return $timeConfig; } function wayfarerRuntimeAdvanceClock( PDO $pdo, int $gameId, array &$runtimeState ): void { $config = wayfarerRuntimeEnsureClock($pdo, $gameId, $runtimeState); if ($config['mode'] === 'none') { return; } $clock =& $runtimeState['runtime']['clock']; $clock['day'] = max(1, (int)($clock['day'] ?? $config['starting_day'])); if ($config['mode'] === 'days') { $clock['tick_in_day'] = max(0, (int)($clock['tick_in_day'] ?? 0)) + 1; if ($clock['tick_in_day'] >= $config['ticks_per_day']) { $clock['day']++; $clock['tick_in_day'] = 0; } return; } $clock['hour'] = max(0, (int)($clock['hour'] ?? $config['starting_hour'])); $clock['tick_in_hour'] = max(0, (int)($clock['tick_in_hour'] ?? 0)) + 1; if ($clock['tick_in_hour'] >= $config['ticks_per_hour']) { $clock['tick_in_hour'] = 0; $clock['hour']++; if ($clock['hour'] >= $config['hours_per_day']) { $clock['hour'] = 0; $clock['day']++; } } } function wayfarerRuntimeClockLabel(array $runtimeState, array $timeConfig): string { if ($timeConfig['mode'] === 'none') { return ''; } $clock = $runtimeState['runtime']['clock'] ?? []; $day = max(1, (int)($clock['day'] ?? $timeConfig['starting_day'])); if ($timeConfig['mode'] === 'days') { return 'Day ' . $day; } $hour = max(0, (int)($clock['hour'] ?? $timeConfig['starting_hour'])); if ($timeConfig['hours_per_day'] === 24) { $displayHour = $hour % 12; if ($displayHour === 0) { $displayHour = 12; } $suffix = $hour < 12 ? 'AM' : 'PM'; return 'Day ' . $day . ' · ' . $displayHour . ':00 ' . $suffix; } return 'Day ' . $day . ' · Hour ' . $hour; } /* * WAYFARER_BIDIRECTIONAL_TRAVEL_V71 * * Runtime direction is journey state, not Region authoring state. +1 follows * the Region's authored forward Tick Effect; -1 reverses it. Keeping the * authored Add/Subtract sign as the base preserves existing Regions while * allowing the same line to be traversed in either direction. */ function wayfarerRuntimeNavigationDirection(array $runtimeState): int { $direction = (int)( $runtimeState['navigation']['direction'] ?? 1 ); return $direction < 0 ? -1 : 1; } function wayfarerRuntimeLinearTravelDirection( array $regionConfig, array $runtimeState ): int { $runtimeDirection = wayfarerRuntimeNavigationDirection( $runtimeState ); $effects = isset($regionConfig['tick_effects']) && is_array($regionConfig['tick_effects']) ? $regionConfig['tick_effects'] : []; foreach ($effects as $effect) { if (!is_array($effect)) { continue; } if ( (string)($effect['target_scope'] ?? '') !== 'navigation' || (string)($effect['target_type'] ?? '') !== 'position' || (string)($effect['target_id'] ?? '') !== 'position' ) { continue; } $branches = wayfarerRuntimeTickEffectBranches($effect); foreach ($branches as $branch) { $operation = strtolower(trim((string)($branch['operation'] ?? ''))); if ($operation === 'subtract') { return -1 * $runtimeDirection; } if ($operation === 'add') { return 1 * $runtimeDirection; } } } return $runtimeDirection; } function wayfarerRuntimeBuildTravelStatus( PDO $pdo, int $gameId, array &$runtimeState ): array { $gameConfig = wayfarerRuntimeLoadGameConfig($pdo, $gameId); $timeConfig = wayfarerRuntimeEnsureClock($pdo, $gameId, $runtimeState); $result = [ 'time_label' => wayfarerRuntimeClockLabel($runtimeState, $timeConfig), 'region_name' => '', 'distance_label' => 'units', 'next_location_id' => 0, 'next_location_name' => '', 'distance_to_next' => null, 'next_label' => '', ]; $regionId = (int)($runtimeState['navigation']['region_id'] ?? 0); $position = $runtimeState['navigation']['position'] ?? null; if ($regionId <= 0 || !is_numeric($position)) { return $result; } $stmt = $pdo->prepare( 'SELECT id, name, navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1' ); $stmt->execute([$regionId, $gameId]); $region = $stmt->fetch(PDO::FETCH_ASSOC); if (!$region) { return $result; } $result['region_name'] = trim((string)($region['name'] ?? '')); if (strtolower(trim((string)($region['navigation_type'] ?? ''))) !== 'linear') { return $result; } $regionConfig = wayfarerRuntimeDecodeConfig($region['config'] ?? null); $distanceLabel = trim((string)($regionConfig['distance_label'] ?? 'units')); $result['distance_label'] = $distanceLabel !== '' ? $distanceLabel : 'units'; $direction = wayfarerRuntimeLinearTravelDirection($regionConfig, $runtimeState); $current = (float)$position; $locationStmt = $pdo->prepare( "SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id" ); $locationStmt->execute([$regionId]); $best = null; $bestDistance = null; foreach ($locationStmt->fetchAll(PDO::FETCH_ASSOC) as $location) { $locationConfig = wayfarerRuntimeDecodeConfig($location['config'] ?? null); if (!isset($locationConfig['position']) || !is_numeric($locationConfig['position'])) { continue; } $locationPosition = (float)$locationConfig['position']; $ahead = $direction < 0 ? $locationPosition < $current : $locationPosition > $current; if (!$ahead) { continue; } $location['_runtime_config'] = $locationConfig; if (!wayfarerRuntimeLocationConditionsPass($pdo, $gameId, $location, $runtimeState)) { continue; } $distance = abs($locationPosition - $current); if ($best === null || $distance < $bestDistance) { $best = $location; $bestDistance = $distance; } } if ($best !== null) { $distance = wayfarerNormalizeRuntimeNumber((float)$bestDistance); $name = trim((string)($best['name'] ?? 'Location')) ?: 'Location'; $result['next_location_id'] = (int)($best['id'] ?? 0); $result['next_location_name'] = $name; $result['distance_to_next'] = $distance; $result['next_label'] = $name . ' — ' . $distance . ' ' . $result['distance_label']; } return $result; } function wayfarerRuntimeActiveTraitDefinitions( array $config ): array { $traits = $config['traits'] ?? []; if (!is_array($traits)) { return []; } $result = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( array_key_exists( 'active', $trait ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $trait['_runtime_id'] = $traitId; $result[] = $trait; } return $result; } function wayfarerRuntimeInitialTraitValue( array $trait ) { if ( array_key_exists( 'initial', $trait ) ) { return $trait['initial']; } $type = strtolower( trim( (string)( $trait['type'] ?? '' ) ) ); if ( $type === 'number' || $type === 'binary' || $type === 'boolean' ) { return 0; } return null; } function wayfarerRuntimeInitialTraitState( array $config ): array { $state = []; foreach ( wayfarerRuntimeActiveTraitDefinitions( $config ) as $trait ) { $traitId = (string)$trait['_runtime_id']; $state[$traitId] = wayfarerRuntimeInitialTraitValue( $trait ); } return $state; } /* |-------------------------------------------------------------------------- | Persisted state for every Region |-------------------------------------------------------------------------- | WAYFARER_ALL_REGION_STATE_V46 | | Game-wide Conditions may inspect a Region that the player has never visited. | Therefore every authored Region needs persistent Trait state, not only the | current Region. | | This helper is additive/recovery-safe: | - existing runtime values are never overwritten; | - missing Regions or newly-added Traits receive their authored initial; | - stale historical values are not destructively removed here. | */ function wayfarerRuntimeEnsureAllRegionTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['regions']) || !is_array($runtimeState['regions']) ) { $runtimeState['regions'] = []; } $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? ORDER BY id " ); $stmt->execute([ $gameId ]); $regions = $stmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($regions as $region) { $regionId = (int)( $region['id'] ?? 0 ); if ($regionId <= 0) { continue; } $regionKey = (string)$regionId; $config = wayfarerRuntimeDecodeConfig( $region['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset( $runtimeState['regions'][$regionKey] ) || !is_array( $runtimeState['regions'][$regionKey] ) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset( $runtimeState['regions'][$regionKey]['traits'] ) || !is_array( $runtimeState['regions'][$regionKey]['traits'] ) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['regions'][$regionKey]['traits'] ) ) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $initialValue; } } } /* |-------------------------------------------------------------------------- | Persisted state for every Location | WAYFARER_ALL_LOCATION_STATE_V56 |-------------------------------------------------------------------------- | | Locations can own Traits, so universal Conditions/Effects need persistent | per-playthrough Location Trait state just like Regions. | */ function wayfarerRuntimeEnsureAllLocationTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['locations']) || !is_array($runtimeState['locations']) ) { $runtimeState['locations'] = []; } $stmt = $pdo->prepare( " SELECT l.id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $location ) { $locationId = (int)($location['id'] ?? 0); if ($locationId <= 0) { continue; } $locationKey = (string)$locationId; $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset($runtimeState['locations'][$locationKey]) || !is_array($runtimeState['locations'][$locationKey]) ) { $runtimeState['locations'][$locationKey] = []; } if ( !isset($runtimeState['locations'][$locationKey]['traits']) || !is_array($runtimeState['locations'][$locationKey]['traits']) ) { $runtimeState['locations'][$locationKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['locations'][$locationKey]['traits'] ) ) { continue; } $runtimeState['locations'][$locationKey]['traits'][$traitId] = $initialValue; } } } function wayfarerRuntimeLoadStartingRegion( PDO $pdo, int $gameId, ?array $gameConfig = null ): ?array { if ($gameId <= 0) { return null; } if ($gameConfig === null) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { return null; } $gameConfig = wayfarerRuntimeDecodeConfig( $rawConfig ); } $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT * FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $startingRegionId, $gameId, ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); return $region ?: null; } function wayfarerRuntimeInitializeGame( PDO $pdo, int $gameUserId, array $setupInput = [] ): array { if ($gameUserId <= 0) { throw new RuntimeException( 'A valid game-user ID is required to initialize the game.' ); } try { $pdo->beginTransaction(); /* * Lock the playthrough row so two initialize requests cannot create * competing starting states. */ $stmt = $pdo->prepare( " SELECT gu.id, gu.game_id, gu.user_id, gu.role, gu.state, g.config AS game_config, g.player_slots AS player_slots FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id WHERE gu.id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } /* * Initialization is intentionally one-way. To restart a development * game, clear wf_game_users.state to NULL first. */ if ( $row['state'] !== null && $row['state'] !== '' ) { throw new RuntimeException( 'This game-user already has runtime state.' ); } $gameId = (int)$row['game_id']; $gameConfig = wayfarerRuntimeDecodeConfig( $row['game_config'] ?? null ); $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { throw new RuntimeException( 'This Game does not have a Starting Region configured.' ); } $startingRegion = wayfarerRuntimeLoadStartingRegion( $pdo, $gameId, $gameConfig ); if (!$startingRegion) { throw new RuntimeException( 'The configured Starting Region does not exist for this Game.' ); } $regionConfig = wayfarerRuntimeDecodeConfig( $startingRegion['config'] ?? null ); $gameTraits = wayfarerRuntimeInitialTraitState( $gameConfig ); $regionTraits = wayfarerRuntimeInitialTraitState( $regionConfig ); $navigation = [ 'region_id' => (int)$startingRegion['id'], /* WAYFARER_BIDIRECTIONAL_TRAVEL_V71 */ 'direction' => 1, ]; /* * Linear travel currently begins at coordinate zero. * Other navigation modes will get their own authored start-state * rules when those modes are implemented. */ if ( strtolower( trim( (string)( $startingRegion['navigation_type'] ?? '' ) ) ) === 'linear' ) { $navigation['position'] = 0; } $regionKey = (string)$startingRegion['id']; $state = [ /* * WAYFARER_RUNTIME_SCHEMA_V55 * WAYFARER_BIDIRECTIONAL_TRAVEL_V71 * Version the persisted playthrough contract explicitly. */ 'schema_version' => 2, 'game' => [ 'traits' => $gameTraits, ], 'navigation' => $navigation, 'regions' => [ $regionKey => [ 'traits' => $regionTraits, ], ], 'runtime' => [ 'tick' => 0, ], ]; /* WAYFARER_INITIALIZE_GAME_CLOCK_V61 */ wayfarerRuntimeInitializeClockFromConfig( $gameConfig, $state ); /* * WAYFARER_ALL_REGION_STATE_V46 * * Global conditions can inspect Region state before that Region has * ever been visited. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $state ); /* WAYFARER_INITIALIZE_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_V49 * * Items and Characters receive canonical per-playthrough state before * any starting setup effects are applied. */ wayfarerRuntimeEnsureEntityState( $pdo, $gameId, $state ); /* * WAYFARER_APPLY_STARTING_CHOICES_V36 * * Validate all player-entered setup data before persisting anything. * Then apply the selected options' starting effects to the canonical * initial runtime state. */ $preparedSetup = wayfarerRuntimePrepareStartingSetup( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $setupInput ); /* * WAYFARER_PLAYER_TRAITS_RUNTIME_V52 * * Persist the validated player-character names before creating the * per-player Trait state. Each slot receives independent values. */ $state['setup'] = [ 'player_names' => $preparedSetup['player_names'], 'choices' => $preparedSetup['choices'], ]; wayfarerRuntimeEnsureGameTraitState( $gameConfig, $state ); wayfarerRuntimeEnsurePlayerState( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $state ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $startingEffectReports = []; foreach ( $preparedSetup['effects'] as $startingEffect ) { /* WAYFARER_STARTING_CHOICE_RANDOM_RUNTIME_V58 */ $resolvedStartingValue = wayfarerRuntimeRollEffectValue( $startingEffect ); if (!$resolvedStartingValue['valid']) { throw new RuntimeException( 'Unable to resolve a Starting Choice effect value: ' . $resolvedStartingValue['error'] ); } $concreteStartingEffect = $startingEffect; $concreteStartingEffect['value'] = $resolvedStartingValue['value']; $report = wayfarerApplyRuntimeEffect( $state, $concreteStartingEffect, (int)$startingRegion['id'], $traitDefinitions ); if ( !empty($report['error']) ) { throw new RuntimeException( 'Unable to apply a Starting Choice effect: ' . $report['error'] ); } $report['value_mode'] = $resolvedStartingValue['mode']; $report['rolled_value'] = $resolvedStartingValue['value']; if ($resolvedStartingValue['mode'] === 'random') { $report['random_min'] = $resolvedStartingValue['min']; $report['random_max'] = $resolvedStartingValue['max']; } $startingEffectReports[] = $report; } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * * Starting-effect reports are intentionally not persisted. The effects * themselves have already mutated canonical state. */ /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 * * A game that begins exactly on an active Location should present that * Location before ordinary Action Mode. */ $startingLocation = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $state ); if ($startingLocation) { /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Starting on a Location is a real Location trigger. Apply its * one-time Effects before opening Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, $gameId, $startingLocation, $state ); $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$startingLocation['id'], 'phase' => 'presentation', 'trigger' => 'initial', 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /* WAYFARER_GAME_ENDINGS_INITIAL_V45 */ wayfarerRuntimeEvaluateGameEndings( $pdo, $gameId, $state ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => $gameId, 'starting_region_id' => (int)$startingRegion['id'], 'state' => $state, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Resolve a game-user record for the tick prototype. * * Preferred: * explicit wf_game_users.id * * Temporary development fallback: * first wf_game_users record. * * The fallback exists only because Wayfarer has not yet wired the play * page to authentication / game selection. Once that exists, remove the * fallback and always pass the active game_user_id explicitly. */ function wayfarerRuntimeResolveGameUserId( PDO $pdo, ?int $requestedId = null ): int { if ($requestedId !== null && $requestedId > 0) { $stmt = $pdo->prepare( " SELECT id FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $requestedId ]); $id = $stmt->fetchColumn(); if ($id !== false) { return (int)$id; } throw new RuntimeException( 'The requested game-user record does not exist.' ); } $id = $pdo ->query( " SELECT id FROM wf_game_users ORDER BY id LIMIT 1 " ) ->fetchColumn(); if ($id === false) { throw new RuntimeException( 'No wf_game_users record exists.' ); } return (int)$id; } /** * Load one game-user runtime state. */ function wayfarerRuntimeLoad( PDO $pdo, int $gameUserId ): array { $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'state' => $state, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /** * Advance exactly one tick. * * This is intentionally the entire v14 game engine: * * state.runtime.tick += 1 * * The transaction and row lock are already in place so later work can add * movement/effects without changing the request architecture. */ /* |-------------------------------------------------------------------------- | Linear Location crossing | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | A tick may move farther than the distance to the next authored Location. | This resolver finds the FIRST active Location crossed in the direction of | travel. The movement is then clamped to that exact authored coordinate. | | Examples: | 45 -> 55, Location 50 => stop at 50 | 55 -> 45, Location 50 => stop at 50 | | The starting coordinate is intentionally exclusive so leaving a Location | does not immediately trigger that same Location again. | */ /* |-------------------------------------------------------------------------- | Location Conditions | WAYFARER_LOCATION_CONDITIONS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeLocationConditionsPass( PDO $pdo, int $gameId, array $location, array &$runtimeState ): bool { $config = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $conditions = $config['conditions'] ?? null; $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; return wayfarerConditionsPass( $conditions, $resolver ); } /* |-------------------------------------------------------------------------- | Game Endings | WAYFARER_GAME_ENDINGS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeEvaluateGameEndings( PDO $pdo, int $gameId, array &$runtimeState ): ?array { /* * WAYFARER_ALL_REGION_STATE_ENDINGS_V46 */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $runtimeState ); /* WAYFARER_RECOVER_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $runtimeState ); if ( isset( $runtimeState['runtime']['ending'] ) && is_array( $runtimeState['runtime']['ending'] ) ) { return $runtimeState['runtime']['ending']; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); $config = wayfarerRuntimeDecodeConfig( $raw === false ? null : $raw ); $endings = $config['endings'] ?? []; if (!is_array($endings)) { return null; } $ordered = []; foreach ( array_values($endings) as $index => $ending ) { if (!is_array($ending)) { continue; } $ending['_runtime_order'] = $index; $ordered[] = $ending; } usort( $ordered, function ( array $a, array $b ): int { $priorityCompare = (int)( $b['priority'] ?? 0 ) <=> (int)( $a['priority'] ?? 0 ); if ($priorityCompare !== 0) { return $priorityCompare; } return (int)( $a['_runtime_order'] ?? 0 ) <=> (int)( $b['_runtime_order'] ?? 0 ); } ); $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; foreach ($ordered as $ending) { if ( !wayfarerConditionsPass( $ending['conditions'] ?? null, $resolver ) ) { continue; } $terminal = [ 'id' => (string)( $ending['id'] ?? '' ), 'title' => (string)( $ending['title'] ?? 'The End' ), 'text' => (string)( $ending['text'] ?? '' ), 'image_media_id' => (int)( $ending['image_media_id'] ?? 0 ), 'priority' => (int)( $ending['priority'] ?? 0 ), 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), ]; if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['ending'] = $terminal; unset( $runtimeState['runtime']['presentation'] ); return $terminal; } return null; } function wayfarerRuntimeFindCrossedLocation( PDO $pdo, int $gameId, int $regionId, $fromPosition, $toPosition, array &$runtimeState ): ?array { /* * WAYFARER_CROSSED_LOCATION_STATE_FIX_V46_1 * * Location Conditions are evaluated against the live runtime state. */ if ( $gameId <= 0 || $regionId <= 0 || !is_numeric($fromPosition) || !is_numeric($toPosition) ) { return null; } $from = (float)$fromPosition; $to = (float)$toPosition; if ($from === $to) { return null; } /* * Crossing interception currently applies only to Linear Regions. */ $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); $forward = $to > $from; $candidate = null; $candidatePosition = null; foreach ($locations as $location) { $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $locationPosition = (float)$config['position']; /* * The starting point is exclusive; the destination is inclusive. */ $crossed = $forward ? ( $locationPosition > $from && $locationPosition <= $to ) : ( $locationPosition < $from && $locationPosition >= $to ); if (!$crossed) { continue; } $location['_runtime_config'] = $config; if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } if ($candidate === null) { $candidate = $location; $candidatePosition = $locationPosition; } else { $nearer = $forward ? ( $locationPosition < $candidatePosition ) : ( $locationPosition > $candidatePosition ); $samePositionLowerId = $locationPosition === $candidatePosition && (int)$location['id'] < (int)$candidate['id']; if ( $nearer || $samePositionLowerId ) { $candidate = $location; $candidatePosition = $locationPosition; } } } if ($candidate === null) { return null; } $candidate['_runtime_position'] = wayfarerNormalizeRuntimeNumber( (float)$candidatePosition ); $candidate['_runtime_config'] = wayfarerRuntimeDecodeConfig( $candidate['config'] ?? null ); return $candidate; } /* |-------------------------------------------------------------------------- | Player spatial context |-------------------------------------------------------------------------- | WAYFARER_PLAYER_CONTEXT_V46_1 | | This is the canonical player-facing spatial label for Action Mode: | | exact eligible Location -> Location name + Region parent context | otherwise -> Region name | | It is returned by runtime APIs so the browser does not keep displaying the | Location name from the page's original server render after movement. | */ function wayfarerRuntimePlayerContext( PDO $pdo, int $gameId, array $runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionName = ''; if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $rawRegionName = $stmt->fetchColumn(); if ($rawRegionName !== false) { $regionName = trim( (string)$rawRegionName ); } } $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); $locationId = 0; $locationName = ''; if ($location) { $locationId = (int)( $location['id'] ?? 0 ); $locationName = trim( (string)( $location['name'] ?? '' ) ); } $contextName = $locationName !== '' ? $locationName : ( $regionName !== '' ? $regionName : 'Actions' ); return [ 'region_id' => $regionId, 'region_name' => $regionName, 'location_id' => $locationId, 'location_name' => $locationName, 'context_name' => $contextName, 'parent_context' => $locationName !== '' ? $regionName : '', ]; } /* |-------------------------------------------------------------------------- | Presentation dismissal |-------------------------------------------------------------------------- | | Presentation is persisted runtime state. This function is intentionally | generic even though v40 renders only Location presentations; Storylets can | use the same mechanism later. | */ function wayfarerRuntimeDismissPresentation( PDO $pdo, int $gameUserId, string $expectedType = '', int $expectedId = 0 ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); $presentation = $state['runtime']['presentation'] ?? null; if ( !is_array($presentation) || empty($presentation['type']) || empty($presentation['id']) ) { throw new RuntimeException( 'There is no active presentation to continue.' ); } if ( $expectedType !== '' && (string)$presentation['type'] !== $expectedType ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } if ( $expectedId > 0 && (int)$presentation['id'] !== $expectedId ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } /* WAYFARER_STORYLET_BRANCH_DISMISS_GUARD_V59 */ if ( (string)($presentation['type'] ?? '') === 'storylet' ) { $eligibleBranches = wayfarerRuntimeEligibleStoryletBranches( $pdo, (int)$row['game_id'], (int)($presentation['id'] ?? 0), $state ); if ($eligibleBranches) { throw new RuntimeException( 'Choose an available Storylet Branch before continuing.' ); } } $state['runtime']['last_presentation'] = $presentation; unset( $state['runtime']['presentation'] ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'dismissed' => $presentation, /* WAYFARER_STORYLET_ACTIONS_AFTER_PRESENTATION_V59 */ 'actions' => wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$row['game_id'], $state ), ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Advance exactly one tick. * * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ function wayfarerRuntimeAdvanceTick( PDO $pdo, int $gameUserId, ?int $requestedTravelDirection = null ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } /* WAYFARER_BIDIRECTIONAL_TRAVEL_V71 */ if ( !isset($state['navigation']) || !is_array($state['navigation']) ) { $state['navigation'] = []; } if ( $requestedTravelDirection !== null && !in_array( $requestedTravelDirection, [-1, 1], true ) ) { throw new RuntimeException( 'Travel direction must be -1 or 1.' ); } $state['navigation']['direction'] = $requestedTravelDirection !== null ? $requestedTravelDirection : wayfarerRuntimeNavigationDirection( $state ); $state['schema_version'] = max( 2, (int)( $state['schema_version'] ?? 1 ) ); /* * WAYFARER_GAME_ENDINGS_TERMINAL_V45 * * Endings are terminal runtime state. */ if ( isset( $state['runtime']['ending'] ) && is_array( $state['runtime']['ending'] ) ) { throw new RuntimeException( 'This game has ended.' ); } /* * Narrative presentation is a true runtime pause, not merely a hidden * browser control. Even Author Mode cannot advance ticks through it. */ if ( isset( $state['runtime']['presentation'] ) && is_array( $state['runtime']['presentation'] ) ) { throw new RuntimeException( 'Continue the current presentation before advancing time.' ); } /* * WAYFARER_ALL_REGION_STATE_TICK_V46 * * Recovery path for playthroughs initialized before v46, and for * Regions/Traits authored after a playthrough began. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_LOCATION_STATE_TICK_RECOVERY_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, (int)$row['game_id'], $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_TICK_V49 * * Recovery path for playthroughs created before V49 and for Entities * or Traits authored after play began. */ wayfarerRuntimeEnsureEntityState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_PLAYER_TRAITS_TICK_RECOVERY_V52 */ wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$row['game_id'], $state ); $regionIdBeforeTick = (int)( $state['navigation']['region_id'] ?? 0 ); $positionBeforeTick = $state['navigation']['position'] ?? null; $currentTick = (int)( $state['runtime']['tick'] ?? 0 ); $state['runtime']['tick'] = $currentTick + 1; /* WAYFARER_ADVANCE_GAME_CLOCK_V61 */ wayfarerRuntimeAdvanceClock( $pdo, (int)$row['game_id'], $state ); /* * Global Game Tick Effects run first. */ wayfarerApplyGameTickEffects( $pdo, (int)$row['game_id'], $state ); /* * Current Region Tick Effects then perform Region-level movement and * state changes. */ wayfarerApplyCurrentRegionTickEffects( $pdo, $state ); /* * Resolve whether the completed movement would have crossed an active * Location. If so, stop at the nearest crossed Location rather than * accepting the overshot position. */ $regionIdAfterMovement = (int)( $state['navigation']['region_id'] ?? 0 ); $positionAfterMovement = $state['navigation']['position'] ?? null; $crossedLocation = null; /* * We only interpret movement as a continuous path while the player * remains in the same Region. Region transitions will later get their * own explicit navigation semantics. */ if ( $regionIdBeforeTick > 0 && $regionIdBeforeTick === $regionIdAfterMovement ) { $crossedLocation = wayfarerRuntimeFindCrossedLocation( $pdo, (int)$row['game_id'], $regionIdAfterMovement, $positionBeforeTick, $positionAfterMovement, $state ); } $arrivalPosition = null; if ($crossedLocation) { $arrivalPosition = $crossedLocation[ '_runtime_position' ]; $state['navigation']['position'] = $arrivalPosition; /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Event Effects execute once at the trigger boundary. They run * before the Location's Tick Effects and before Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, (int)$row['game_id'], $crossedLocation, $state ); } /* * WAYFARER_LOCATION_EFFECTS_ONLY_RUNTIME_V43 * * Locations no longer own Tick Effects. Game and Region Tick Effects * have already advanced the simulation; a crossed Location contributes * its one-time Effects and then interrupts with Presentation Mode. */ if ($crossedLocation) { $state['navigation']['position'] = $arrivalPosition; $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$crossedLocation['id'], 'phase' => 'presentation', 'trigger' => 'arrival', 'tick' => (int)$state['runtime']['tick'], ]; $state['runtime']['last_location_arrival'] = [ 'location_id' => (int)$crossedLocation['id'], 'region_id' => $regionIdAfterMovement, 'position' => $arrivalPosition, 'tick' => (int)$state['runtime']['tick'], ]; } /* WAYFARER_GAME_ENDINGS_AFTER_TICK_V45 */ $ending = wayfarerRuntimeEvaluateGameEndings( $pdo, (int)$row['game_id'], $state ); $state['runtime']['last_tick_at'] = gmdate('c'); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId ]); $pdo->commit(); $presentation = $state['runtime']['presentation'] ?? null; $pausedForPresentation = is_array($presentation) && !empty($presentation['type']) && !empty($presentation['id']); $ending = $state['runtime']['ending'] ?? null; $ended = is_array($ending) && !empty($ending['id']); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'tick' => (int)$state['runtime']['tick'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'pause' => $pausedForPresentation || $ended, 'pause_reason' => $ended ? 'Game ended.' : ( $pausedForPresentation ? 'Location reached.' : '' ), 'presentation' => $pausedForPresentation ? $presentation : null, 'ending' => $ended ? $ending : null, /* WAYFARER_STORYLET_ACTIONS_TICK_API_V59 */ 'actions' => !$pausedForPresentation && !$ended ? wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$row['game_id'], $state ) : [], /* WAYFARER_TRAVEL_TIME_HUD_V61 */ 'travel' => wayfarerRuntimeBuildTravelStatus( $pdo, (int)$row['game_id'], $state ), ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: _update_backups/v73-20260928-161130/admin/games/edit.php ================================================================================ null, 'name' => '', 'slug' => '', 'description' => '', 'instructions' => '', 'player_slots' => 0, 'config' => null, 'status' => 'draft' ]; $traits = []; $playerTraits = []; $config = []; /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ $gameTickEffects = []; $gameplayTickEffects = []; $startingChoices = []; $startingChoicesEditor = []; $startingChoiceItemTargets = []; /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 */ $gameEndings = []; $gameEndingsEditor = []; $error = ''; // ================================================================ // LOAD EXISTING GAME // ================================================================ if (isset($_GET['id'])) { $stmt = $pdo->prepare(" SELECT * FROM wf_games WHERE id = ? "); $stmt->execute([(int)$_GET['id']]); $existingGame = $stmt->fetch(); if (!$existingGame) { die('Game not found.'); } $game = array_merge($game, $existingGame); if (!empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } if ( isset($config['traits']) && is_array($config['traits']) ) { $traits = $config['traits']; } /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ if ( isset($config['player_traits']) && is_array($config['player_traits']) ) { $playerTraits = array_values( $config['player_traits'] ); } /* WAYFARER_LOAD_GAME_TICK_EFFECTS_V33 */ if ( isset($config['tick_effects']) && is_array($config['tick_effects']) ) { $gameTickEffects = array_values( $config['tick_effects'] ); } if ( isset($config['setup']['choices']) && is_array($config['setup']['choices']) ) { $startingChoices = array_values( $config['setup']['choices'] ); } /* WAYFARER_GAME_ENDINGS_V45 */ if ( isset($config['endings']) && is_array($config['endings']) ) { $gameEndings = array_values( $config['endings'] ); } } $gameplayTickEffects = $gameTickEffects; $startingChoicesEditor = $startingChoices; $gameEndingsEditor = $gameEndings; // ================================================================ // SAVE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $game['id'] = !empty($_POST['id']) ? (int)$_POST['id'] : null; $game['name'] = trim($_POST['name'] ?? ''); $game['slug'] = trim($_POST['slug'] ?? ''); $game['description'] = trim($_POST['description'] ?? ''); $game['instructions'] = trim($_POST['instructions'] ?? ''); $game['player_slots'] = max(0, (int)($_POST['player_slots'] ?? 0)); $game['status'] = $_POST['status'] ?? 'draft'; // ------------------------------------------------------------ // BUILD GAME + PLAYER TRAIT DEFINITIONS // WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 // ------------------------------------------------------------ $traitResult = buildTraitsFromPost( $_POST['traits'] ?? [], ['status'] ); $traits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } $playerTraitResult = buildTraitsFromPost( $_POST['player_traits'] ?? [], ['status'] ); $playerTraits = $playerTraitResult['traits']; if ( $error === '' && $playerTraitResult['error'] !== '' ) { $error = 'Player Traits: ' . $playerTraitResult['error']; } /* WAYFARER_GAME_SETTINGS_UNIVERSAL_TARGETS_V56 */ $gameplayRuntimeTargets = gameplayRuntimeStateTargets( $pdo, (int)($game['id'] ?? 0), $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ) ); /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 */ $startingChoiceItemTargets = wayfarerStartingChoiceItemQuantityTargets( $pdo, (int)($game['id'] ?? 0) ); /* WAYFARER_BUILD_STARTING_CHOICES_V36 */ /* WAYFARER_STARTING_CHOICES_CANONICAL_EMPTY_V36_1 */ $startingChoicesSubmitted = isset($_POST['setup_choices_present']) || array_key_exists( 'setup_choices', $_POST ); $postedStartingChoices = isset($_POST['setup_choices']) && is_array($_POST['setup_choices']) ? array_values( $_POST['setup_choices'] ) : []; $startingChoicesEditor = $postedStartingChoices; if ($error === '') { $startingChoiceResult = wayfarerBuildStartingChoicesFromPost( $postedStartingChoices, $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ), $startingChoiceItemTargets, $gameplayRuntimeTargets ); if ( $startingChoiceResult['error'] !== '' ) { $error = $startingChoiceResult['error']; } else { $startingChoices = $startingChoiceResult['choices']; $startingChoicesEditor = $startingChoices; } } /* WAYFARER_BUILD_GAME_ENDINGS_V45 */ $postedGameEndings = isset($_POST['endings']) && is_array($_POST['endings']) ? array_values( $_POST['endings'] ) : []; $gameEndingsEditor = $postedGameEndings; if ($error === '') { $endingResult = wayfarerBuildGameEndingsFromPost( $pdo, (int)($game['id'] ?? 0), $postedGameEndings, $traits ); if ( $endingResult['error'] !== '' ) { $error = $endingResult['error']; } else { $gameEndings = $endingResult['endings']; $gameEndingsEditor = $gameEndings; } } /* WAYFARER_BUILD_GAME_TICK_EFFECTS_V33 */ $postedGameTickEffects = isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ? array_values( $_POST['tick_effects'] ) : []; $gameplayTickEffects = $postedGameTickEffects; if ($error === '') { $gameTickEffectResult = gameplayBuildTickEffectsFromPost( $gameplayRuntimeTargets, $postedGameTickEffects, $gameplayRuntimeTargets ); if ( $gameTickEffectResult['error'] !== '' ) { $error = $gameTickEffectResult['error']; } else { $gameTickEffects = $gameTickEffectResult['effects']; $gameplayTickEffects = $gameTickEffects; } } // ------------------------------------------------------------ // VALIDATE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['name'] === '') { $error = 'Game name is required.'; } elseif ($game['slug'] === '') { $error = 'Slug is required.'; } } // ------------------------------------------------------------ // BUILD CONFIG JSON // ------------------------------------------------------------ if ($error === '') { /* * Preserve any other configuration already stored for the game. * Traits own only the "traits" section of config. */ if (empty($config) && !empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } $startingRegionId = !empty($_POST['starting_region_id']) ? (int)$_POST['starting_region_id'] : null; if ($startingRegionId !== null) { $config['starting_region_id'] = $startingRegionId; } else { unset($config['starting_region_id']); } /* WAYFARER_GAME_TIME_SETTINGS_SAVE_V61 */ $timeMode = strtolower(trim((string)($_POST['time_tracking'] ?? 'none'))); if (!in_array($timeMode, ['none', 'days', 'hours'], true)) { $timeMode = 'none'; } if ($timeMode === 'none') { unset($config['time']); } else { $startingDay = max(1, (int)($_POST['time_starting_day'] ?? 1)); if ($timeMode === 'days') { $config['time'] = [ 'mode' => 'days', 'starting_day' => $startingDay, 'ticks_per_day' => max(1, (int)($_POST['time_ticks_per_day'] ?? 1)), ]; } else { $hoursPerDay = max(1, (int)($_POST['time_hours_per_day'] ?? 24)); $startingHour = max(0, (int)($_POST['time_starting_hour'] ?? 8)); if ($startingHour >= $hoursPerDay) { $startingHour = $hoursPerDay - 1; } $config['time'] = [ 'mode' => 'hours', 'starting_day' => $startingDay, 'starting_hour' => $startingHour, 'ticks_per_hour' => max(1, (int)($_POST['time_ticks_per_hour'] ?? 1)), 'hours_per_day' => $hoursPerDay, ]; } } $config['traits'] = array_values( $traits ); /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ $config['player_traits'] = array_values( $playerTraits ); /* WAYFARER_SAVE_GAME_TICK_EFFECTS_V33 */ if ($gameTickEffects) { $config['tick_effects'] = array_values( $gameTickEffects ); } else { unset( $config['tick_effects'] ); } /* WAYFARER_SAVE_STARTING_CHOICES_V36 */ /* * WAYFARER_STARTING_CHOICES_CANONICAL_EMPTY_V36_1 * * If the Setup editor participated in this submission, its current * collection is the canonical collection. In particular, deleting the * final card must persist an explicit empty array rather than relying * on the absence of setup_choices in POST. */ if ($startingChoicesSubmitted) { if ( !isset($config['setup']) || !is_array($config['setup']) ) { $config['setup'] = []; } $config['setup']['choices'] = array_values( $startingChoices ); } /* WAYFARER_SAVE_GAME_ENDINGS_V45 */ $config['endings'] = array_values( $gameEndings ); $game['config'] = json_encode( $config, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ($game['config'] === false) { $error = 'Unable to encode game configuration.'; } } // ------------------------------------------------------------ // WRITE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['id']) { $stmt = $pdo->prepare(" UPDATE wf_games SET name = ?, slug = ?, description = ?, instructions = ?, player_slots = ?, config = ?, status = ? WHERE id = ? "); $stmt->execute([ $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'], $game['id'] ]); } else { $uuid = bin2hex(random_bytes(16)); $stmt = $pdo->prepare(" INSERT INTO wf_games ( uuid, name, slug, description, instructions, player_slots, config, status ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $uuid, $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'] ]); $game['id'] = (int)$pdo->lastInsertId(); } /* * WAYFARER_STAY_ON_GAME_SETTINGS_V38 * * Successful saves remain on the same Game Settings record and reopen * the tab the author was using. */ $savedGameTab = (string)( $_POST['active_tab'] ?? 'basics' ); if ( !in_array( $savedGameTab, [ 'basics', 'setup', 'traits', 'gameplay', 'endings', ], true ) ) { $savedGameTab = 'basics'; } header( 'Location: edit.php?id=' . (int)$game['id'] . '&tab=' . rawurlencode( $savedGameTab ) ); exit; } } // ================================================================ // LOAD REGIONS FOR STARTING REGION // ================================================================ $regions = []; if (!empty($game['id'])) { $regionStmt = $pdo->prepare(" SELECT id, name FROM wf_regions WHERE game_id = ? ORDER BY name "); $regionStmt->execute([ $game['id'] ]); $regions = $regionStmt->fetchAll(); } /* WAYFARER_GAME_SETTINGS_UNIVERSAL_TARGETS_RENDER_V56 */ $gameplayRuntimeTargets = gameplayRuntimeStateTargets( $pdo, (int)($game['id'] ?? 0), $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ) ); /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_RENDER_V54 */ $startingChoiceItemTargets = wayfarerStartingChoiceItemQuantityTargets( $pdo, (int)($game['id'] ?? 0) ); ?> <?= $game['id'] ? 'Edit Game' : 'New Game' ?> - Wayfarer


Example: stormgate

================================================================================ FILE: _update_backups/v73-20260928-161130/play/includes/effects.php ================================================================================ true, 'value' => wayfarerNormalizeRuntimeNumber( (float)$effectValue ), 'error' => '', ]; } return [ 'applied' => true, 'value' => $effectValue, 'error' => '', ]; } if ( $operation !== 'add' && $operation !== 'subtract' ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Unsupported operation: ' . $operation, ]; } if ( !is_numeric($currentValue) || !is_numeric($effectValue) ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Add/subtract requires numeric values.', ]; } $current = (float)$currentValue; $amount = (float)$effectValue; $result = $operation === 'add' ? $current + $amount : $current - $amount; return [ 'applied' => true, 'value' => wayfarerNormalizeRuntimeNumber( $result ), 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Tick Effect branch runtime |-------------------------------------------------------------------------- | | A Tick Effect owns one target and an ordered list of result branches. | Conditions are evaluated against the CURRENT runtime state. Because Tick | Effects execute sequentially, a later Tick Effect can see state changes made | by an earlier Tick Effect during the same tick. | */ function wayfarerRuntimeResolveConditionTarget( array $runtimeState, int $currentRegionId, string $scope, string $targetType, string $targetId ): array { $scope = strtolower( trim( $scope ) ); $targetType = strtolower( trim( $targetType ) ); $targetId = trim( $targetId ); // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $targetType === 'trait' && $targetId !== '' ) { $traits = $runtimeState['game']['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // SPECIFIC REGION TRAIT // WAYFARER_SPECIFIC_REGION_CONDITION_SCOPE_V46 // ------------------------------------------------------------ // // Game-level condition editors store a Region-owned Trait as // "region:". This lets a global rule inspect that Region whether // or not the player is currently there. // if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^region:([1-9][0-9]*)$/', $scope, $regionScopeMatch ) === 1 ) { $regionKey = (string)( (int)$regionScopeMatch[1] ); $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $targetType === 'trait' && $targetId !== '' && $currentRegionId > 0 ) { $regionKey = (string)$currentRegionId; $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // PLAYER / LOCATION / ENTITY TRAITS // WAYFARER_RUNTIME_UNIVERSAL_TARGET_RESOLVER_V56 // ------------------------------------------------------------ if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^player:([0-9]+)$/', $scope, $playerScopeMatch ) === 1 ) { $playerKey = (string)((int)$playerScopeMatch[1]); $traits = $runtimeState['players'][$playerKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^location:([1-9][0-9]*)$/', $scope, $locationScopeMatch ) === 1 ) { $locationKey = (string)((int)$locationScopeMatch[1]); $traits = $runtimeState['locations'][$locationKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityScopeMatch ) === 1 ) { $entityKey = (string)((int)$entityScopeMatch[1]); $traits = $runtimeState['entities'][$entityKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $targetType === 'position' && $targetId === 'position' ) { if ( isset( $runtimeState['navigation'] ) && is_array( $runtimeState['navigation'] ) && array_key_exists( 'position', $runtimeState['navigation'] ) ) { return [ 'found' => true, 'value' => $runtimeState['navigation']['position'], ]; } return [ 'found' => false, 'value' => null, ]; } return [ 'found' => false, 'value' => null, ]; } function wayfarerRuntimeTickEffectBranches( array $effect ): array { /* * v28+ canonical structure. * * If the branches key exists, it is authoritative. An empty branches * array therefore means "this effect has no executable results" rather * than silently falling back to legacy compatibility fields. */ if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * Legacy pre-v28 structure. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'conditions', $effect ) ) { $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $effect['operation'] ?? '', 'value_mode' => $effect['value_mode'] ?? 'fixed', ]; if ( strtolower( trim( (string)$branch['value_mode'] ) ) === 'random' ) { $branch['value_min'] = $effect['value_min'] ?? 0; $branch['value_max'] = $effect['value_max'] ?? 0; } else { $branch['value'] = $effect['value'] ?? 0; } return [ $branch ]; } return []; } function wayfarerRuntimeSelectTickEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeTickEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Tick Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; /* * Validate before evaluating. Invalid authored data fails closed and * does NOT fall through to OTHERWISE, because doing so could silently * produce the wrong gameplay result. */ $validation = wayfarerConditionValidateNode( $conditions ); if (!$validation['valid']) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Tick Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Runtime Trait bounds |-------------------------------------------------------------------------- | | WAYFARER_RUNTIME_TRAIT_BOUNDS_V30 | | Authored numeric Trait min/max values are runtime invariants. | State is normalized before condition evaluation and every subsequent | mutation is clamped again before it is written. | */ function wayfarerRuntimeDecodeTraitConfig( $rawConfig ): array { if (is_array($rawConfig)) { return $rawConfig; } if ( $rawConfig === null || $rawConfig === '' ) { return []; } $decoded = json_decode( (string)$rawConfig, true ); return is_array($decoded) ? $decoded : []; } function wayfarerRuntimeIndexTraitDefinitions( array $traits ): array { $definitions = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $definitions[$traitId] = $trait; } return $definitions; } function wayfarerRuntimeLoadTickTraitDefinitions( PDO $pdo, int $gameId, array $regionConfig ): array { $gameConfig = []; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawGameConfig = $stmt->fetchColumn(); if ($rawGameConfig !== false) { $gameConfig = wayfarerRuntimeDecodeTraitConfig( $rawGameConfig ); } } /* WAYFARER_RUNTIME_UNIVERSAL_DEFINITIONS_V56 */ $definitions = [ 'game' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['traits'] ?? null ) ? $gameConfig['traits'] : [] ), 'player' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['player_traits'] ?? null ) ? $gameConfig['player_traits'] : [] ), 'region' => wayfarerRuntimeIndexTraitDefinitions( is_array( $regionConfig['traits'] ?? null ) ? $regionConfig['traits'] : [] ), 'regions' => [], 'locations' => [], 'entities' => [], ]; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $definitions['regions'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } $stmt = $pdo->prepare( " SELECT l.id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $definitions['locations'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } $stmt = $pdo->prepare( " SELECT id, entity_type, config FROM wf_entities WHERE game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); if ( function_exists( 'wayfarerEntityActiveTraitDefinitions' ) ) { $definitions['entities'][$id] = wayfarerEntityActiveTraitDefinitions( $config, (string)( $row['entity_type'] ?? '' ) ); } else { $definitions['entities'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } } } return $definitions; } function wayfarerRuntimeClampValueToTraitDefinition( $value, ?array $definition ): array { $result = [ 'valid' => true, 'value' => $value, 'clamped' => false, 'min' => null, 'max' => null, 'error' => '', ]; /* * Bounds currently belong only to authored Number Traits. */ if ( !$definition || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' ) { return $result; } if (!is_numeric($value)) { $result['valid'] = false; $result['error'] = 'Numeric Trait runtime state must be numeric.'; return $result; } $min = isset($definition['min']) && $definition['min'] !== '' && is_numeric($definition['min']) ? (float)$definition['min'] : null; $max = isset($definition['max']) && $definition['max'] !== '' && is_numeric($definition['max']) ? (float)$definition['max'] : null; $numeric = (float)$value; $bounded = $numeric; if ( $min !== null && $bounded < $min ) { $bounded = $min; } if ( $max !== null && $bounded > $max ) { $bounded = $max; } $result['value'] = wayfarerNormalizeRuntimeNumber( $bounded ); $result['clamped'] = $bounded !== $numeric; $result['min'] = $min === null ? null : wayfarerNormalizeRuntimeNumber( $min ); $result['max'] = $max === null ? null : wayfarerNormalizeRuntimeNumber( $max ); return $result; } function wayfarerRuntimeApplyBoundedTraitOperation( $storedBefore, string $operation, $effectValue, ?array $definition ): array { /* * Repair an already-illegal persisted value BEFORE applying the new * operation. Example: 120 with max 100 and subtract 10 becomes * 100 - 10 = 90, not 120 - 10 = 110 -> 100. */ $beforeBound = wayfarerRuntimeClampValueToTraitDefinition( $storedBefore, $definition ); if (!$beforeBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $storedBefore, 'requested_after' => $storedBefore, 'after' => $storedBefore, 'clamped' => false, 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $beforeBound['error'], ]; } $normalizedBefore = $beforeBound['value']; $operationResult = wayfarerApplyRuntimeOperation( $normalizedBefore, $operation, $effectValue ); if (!$operationResult['applied']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $normalizedBefore, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $operationResult['error'], ]; } $requestedAfter = $operationResult['value']; $afterBound = wayfarerRuntimeClampValueToTraitDefinition( $requestedAfter, $definition ); if (!$afterBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $afterBound['error'], ]; } return [ 'applied' => true, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $afterBound['value'], 'clamped' => $beforeBound['clamped'] || $afterBound['clamped'], 'min' => $afterBound['min'], 'max' => $afterBound['max'], 'error' => '', ]; } function wayfarerRuntimeClampApplicableTraitState( array &$runtimeState, array $traitDefinitions, int $currentRegionId ): array { $repairs = []; // ------------------------------------------------------------ // GAME TRAITS // ------------------------------------------------------------ $gameTraits = $runtimeState['game']['traits'] ?? null; if (is_array($gameTraits)) { foreach ( $traitDefinitions['game'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $gameTraits ) ) { continue; } $before = $runtimeState['game']['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['game']['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } // ------------------------------------------------------------ // CURRENT REGION TRAITS // ------------------------------------------------------------ $regionKey = (string)$currentRegionId; $regionTraits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if (is_array($regionTraits)) { foreach ( $traitDefinitions['region'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $regionTraits ) ) { continue; } $before = $runtimeState['regions'][$regionKey]['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } return $repairs; } function wayfarerApplyRuntimeTickEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectTickEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection['branch_index'], 'applied' => false, 'error' => $selection['error'], ]; } /* * No conditional branch matched and there was no OTHERWISE. * This is a valid "do nothing this tick" result. */ if (!$selection['matched']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection['branch_index']; $operation = strtolower( trim( (string)( $branch['operation'] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; /* * Explicit No Change: * the branch DID match, so this Tick Effect stops here, but state does * not mutate. */ if ($operation === 'none') { $current = wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $type, $targetId ); return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'amount' => null, 'before' => $current['found'] ? $current['value'] : null, 'after' => $current['found'] ? $current['value'] : null, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'applied' => false, 'no_change' => true, 'error' => '', ]; } /* WAYFARER_TICK_EFFECT_RANDOM_RUNTIME_V58 */ $resolvedValue = wayfarerRuntimeRollEffectValue( $branch ); if (!$resolvedValue['valid']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'applied' => false, 'error' => $resolvedValue['error'], ]; } /* * WAYFARER_BIDIRECTIONAL_TRAVEL_TICK_EFFECT_V71 * * Tick movement is relative to the player's current journey direction. * Only Tick Effects are reversed here. One-time narrative Effects remain * literal state changes, so an authored "Subtract 3 position" effect does * not unexpectedly change meaning because the player is traveling back. */ if ( $scope === 'navigation' && $type === 'position' && $targetId === 'position' && wayfarerRuntimeNavigationDirection( $runtimeState ) < 0 ) { if ($operation === 'add') { $operation = 'subtract'; } elseif ($operation === 'subtract') { $operation = 'add'; } } /* * Apply ONLY the selected result branch. The legacy effect-level * operation/value fields are deliberately ignored when branches exist. */ $resolvedEffect = $effect; $resolvedEffect['operation'] = $operation; $resolvedEffect['value'] = $resolvedValue['value']; $report = wayfarerApplyRuntimeEffect( $runtimeState, $resolvedEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; $report['value_mode'] = $resolvedValue['mode']; $report['rolled_value'] = $resolvedValue['value']; if ($resolvedValue['mode'] === 'random') { $report['random_min'] = $resolvedValue['min']; $report['random_max'] = $resolvedValue['max']; } return $report; } function wayfarerApplyRuntimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); $operation = strtolower( trim( (string)( $effect['operation'] ?? '' ) ) ); $effectValue = $effect['value'] ?? 0; // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $type === 'position' && $targetId === 'position' ) { if ( !isset($runtimeState['navigation']) || !is_array($runtimeState['navigation']) ) { $runtimeState['navigation'] = []; } $before = $runtimeState['navigation']['position'] ?? 0; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); $after = $before; if ( $result['applied'] && !is_numeric($result['value']) ) { $result['applied'] = false; $result['error'] = 'Navigation position must be numeric.'; } if ($result['applied']) { /* * Global navigation rule: * position may never be less than zero. */ $after = max( 0, $result['value'] ); $runtimeState['navigation']['position'] = $after; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $after, 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['game']) || !is_array($runtimeState['game']) ) { $runtimeState['game'] = []; } if ( !isset($runtimeState['game']['traits']) || !is_array($runtimeState['game']['traits']) ) { $runtimeState['game']['traits'] = []; } $before = $runtimeState['game']['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['game'][$targetId] ) && is_array( $traitDefinitions['game'][$targetId] ) ? $traitDefinitions['game'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); /* * Persist the normalized value even when a malformed operation fails * after an existing illegal value was repaired. */ if ( $result['normalized_before'] !== $before ) { $runtimeState['game']['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['game']['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // ITEM ENTITY QUANTITY // WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 // ------------------------------------------------------------ if ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityScopeMatch ) === 1 && ( (string)( $effect['target_system_key'] ?? '' ) === 'quantity' || $targetId === 'sys_item_quantity' ) ) { $entityKey = (string)( (int)$entityScopeMatch[1] ); if ( !isset( $runtimeState['entities'][$entityKey] ) || !is_array( $runtimeState['entities'][$entityKey] ) || !isset( $runtimeState['entities'][$entityKey]['traits'] ) || !is_array( $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Entity runtime state was not initialized.', ]; } if ( !array_key_exists( $targetId, $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Quantity runtime state was not found.', ]; } $before = $runtimeState['entities'][$entityKey]['traits'][$targetId]; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); if ( !$result['applied'] || !is_numeric( $result['value'] ?? null ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $before, 'applied' => false, 'error' => $result['error'] !== '' ? $result['error'] : 'Item Quantity must remain numeric.', ]; } $requestedAfter = wayfarerNormalizeRuntimeNumber( (float)$result['value'] ); /* * Quantity is globally non-negative. */ $after = wayfarerNormalizeRuntimeNumber( max( 0.0, (float)$requestedAfter ) ); $runtimeState['entities'][$entityKey]['traits'][$targetId] = $after; /* * Discovery is persistent. Receiving any positive starting quantity * discovers the Item; setting it back to zero never undiscoveres it. */ if ( (float)$after > 0 ) { $runtimeState['entities'][$entityKey]['discovered'] = true; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_system_key' => 'quantity', 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'requested_after' => $requestedAfter, 'after' => $after, 'clamped' => (float)$after !== (float)$requestedAfter, 'min' => 0, 'max' => null, 'applied' => true, 'error' => '', ]; } // ------------------------------------------------------------ // SPECIFIC PLAYER / REGION / LOCATION / ENTITY TRAITS // WAYFARER_RUNTIME_UNIVERSAL_EFFECT_TARGETS_V56 // ------------------------------------------------------------ $stateBucket = null; $stateKey = null; $definition = null; if ( $type === 'trait' && $targetId !== '' && preg_match( '/^player:([0-9]+)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'players'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['player'][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^region:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'regions'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['regions'][$stateKey][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^location:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'locations'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['locations'][$stateKey][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 && $targetId !== 'sys_item_quantity' ) { $stateBucket = 'entities'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['entities'][$stateKey][$targetId] ?? null; } if ( $stateBucket !== null && $stateKey !== null ) { if ( !isset($runtimeState[$stateBucket][$stateKey]) || !is_array($runtimeState[$stateBucket][$stateKey]) || !isset($runtimeState[$stateBucket][$stateKey]['traits']) || !is_array($runtimeState[$stateBucket][$stateKey]['traits']) || !array_key_exists( $targetId, $runtimeState[$stateBucket][$stateKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Runtime Trait target was not found.', ]; } $before = $runtimeState[$stateBucket][$stateKey]['traits'][$targetId]; /* * Party Membership is mutable runtime state, but still a binary * Wayfarer system property. The 0/1 rule is enforced internally and is * never exposed as a separate Target/max metadata value. */ if ( $stateBucket === 'entities' && $targetId === 'sys_character_party_member' ) { $operationResult = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); if ( !$operationResult['applied'] || !is_numeric( $operationResult['value'] ?? null ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $before, 'applied' => false, 'error' => $operationResult['error'] !== '' ? $operationResult['error'] : 'Party Membership requires a numeric 0/1 result.', ]; } $after = (float)$operationResult['value'] > 0 ? 1 : 0; $runtimeState[$stateBucket][$stateKey]['traits'][$targetId] = $after; return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_system_key' => 'party_member', 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $after, 'applied' => true, 'error' => '', ]; } $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, is_array($definition) ? $definition : null ); if ($result['applied']) { $runtimeState[$stateBucket][$stateKey]['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // PLAYER TRAIT // WAYFARER_STARTING_CHOICE_PLAYER_TARGETS_V53 // ------------------------------------------------------------ if ( $scope === 'player' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['players']) || !is_array($runtimeState['players']) ) { $runtimeState['players'] = []; } $selector = strtolower( trim( (string)( $effect['target_selector'] ?? '' ) ) ); $targetKeys = []; if ($selector === 'all') { foreach ( $runtimeState['players'] as $playerKey => $playerState ) { if (is_array($playerState)) { $targetKeys[] = (string)$playerKey; } } } elseif ( $selector === 'slot' && array_key_exists( 'target_slot', $effect ) ) { $slot = max( 0, (int)$effect['target_slot'] ); $targetKeys[] = (string)$slot; } else { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect requires an All Players or Player Slot target.', ]; } if (empty($targetKeys)) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect did not resolve any Player Characters.', ]; } $definition = isset( $traitDefinitions['player'][$targetId] ) && is_array( $traitDefinitions['player'][$targetId] ) ? $traitDefinitions['player'][$targetId] : null; $results = []; $firstError = ''; foreach ($targetKeys as $playerKey) { if ( !isset( $runtimeState['players'][$playerKey] ) || !is_array( $runtimeState['players'][$playerKey] ) ) { $firstError = 'Player Character slot ' . ((int)$playerKey + 1) . ' does not exist in runtime state.'; $results[$playerKey] = [ 'applied' => false, 'error' => $firstError, ]; break; } if ( !isset( $runtimeState['players'][$playerKey]['traits'] ) || !is_array( $runtimeState['players'][$playerKey]['traits'] ) ) { $runtimeState['players'][$playerKey]['traits'] = []; } $before = $runtimeState['players'][$playerKey]['traits'][$targetId] ?? 0; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['after']; } $results[$playerKey] = [ 'slot' => (int)$playerKey, 'name' => (string)( $runtimeState['players'][$playerKey]['name'] ?? ( 'Player ' . ((int)$playerKey + 1) ) ), 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; if ( !$result['applied'] || $result['error'] !== '' ) { $firstError = $result['error'] !== '' ? $result['error'] : 'Player Trait effect could not be applied.'; break; } } $allApplied = $firstError === ''; return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_selector' => $selector, 'target_slot' => $selector === 'slot' ? (int)( $effect['target_slot'] ?? 0 ) : null, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'results' => $results, 'applied' => $allApplied, 'error' => $firstError, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $type === 'trait' && $targetId !== '' ) { $regionKey = (string)$currentRegionId; if ( !isset($runtimeState['regions']) || !is_array($runtimeState['regions']) ) { $runtimeState['regions'] = []; } if ( !isset($runtimeState['regions'][$regionKey]) || !is_array($runtimeState['regions'][$regionKey]) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset($runtimeState['regions'][$regionKey]['traits']) || !is_array($runtimeState['regions'][$regionKey]['traits']) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } $before = $runtimeState['regions'][$regionKey]['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['region'][$targetId] ) && is_array( $traitDefinitions['region'][$targetId] ) ? $traitDefinitions['region'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Unsupported runtime target.', ]; } /* |-------------------------------------------------------------------------- | Game Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_GAME_TICK_EFFECT_RUNTIME_V33 | | Game Tick Effects execute once on every successful runtime tick before | the active Region's Tick Effects. This creates a deterministic layering: | | Game rules -> Region rules | */ function wayfarerApplyGameTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { if ($gameId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active game is available for Game Tick Effects.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { $reports = [ [ 'applied' => false, 'error' => 'Game ' . $gameId . ' was not found.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $gameConfig = []; if ( $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, [] ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $gameEffects = $gameConfig['tick_effects'] ?? []; if (!is_array($gameEffects)) { $gameEffects = []; } $reports = []; foreach ($gameEffects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Game Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; $runtimeState['runtime']['last_game_trait_bound_repairs'] = $boundRepairs; return $reports; } /* |-------------------------------------------------------------------------- | Generalized one-time Effect runtime | WAYFARER_GENERALIZED_EFFECT_RUNTIME_V43 |-------------------------------------------------------------------------- | | Conditions use the same shared evaluator as Tick Effect branches. | Random values are resolved exactly once, immediately before the selected | result mutates runtime state. | */ function wayfarerRuntimeEffectBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * v42 compatibility: one Effect was one unconditional operation/value. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $branch = [ 'conditions' => null, 'operation' => $effect['operation'] ?? 'add', 'value_mode' => $effect['value_mode'] ?? 'fixed', ]; if ( strtolower( trim( (string)( $branch[ 'value_mode' ] ) ) ) === 'random' ) { $branch['value_min'] = $effect['value_min'] ?? 0; $branch['value_max'] = $effect['value_max'] ?? 0; } else { $branch['value'] = $effect['value'] ?? 0; } return [ $branch ]; } return []; } function wayfarerRuntimeSelectEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; $validation = wayfarerConditionValidateNode( $conditions ); if ( !$validation['valid'] ) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } function wayfarerRuntimeRollEffectValue( array $branch ): array { $mode = strtolower( trim( (string)( $branch[ 'value_mode' ] ?? 'fixed' ) ) ); if ($mode !== 'random') { $value = $branch['value'] ?? null; /* WAYFARER_FIXED_SCALAR_EFFECT_RUNTIME_V58 */ return [ 'valid' => true, 'mode' => 'fixed', 'value' => is_numeric($value) ? wayfarerNormalizeRuntimeNumber( (float)$value ) : $value, 'min' => null, 'max' => null, 'error' => '', ]; } $minimum = $branch['value_min'] ?? null; $maximum = $branch['value_max'] ?? null; if ( !is_numeric($minimum) || !is_numeric($maximum) ) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => $minimum, 'max' => $maximum, 'error' => 'Random Effect requires numeric Min and Max values.', ]; } $min = (float)$minimum; $max = (float)$maximum; if ($min > $max) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => 'Random Effect Min cannot be greater than Max.', ]; } if ( floor($min) === $min && floor($max) === $max ) { $rolled = random_int( (int)$min, (int)$max ); } else { /* * Continuous decimal range. Six decimal places of entropy are more * than sufficient for authored simulation values and preserve generic * numeric Min/Max support without requiring authored precision metadata. */ $fraction = random_int( 0, 1000000 ) / 1000000; $rolled = $min + (($max - $min) * $fraction); } return [ 'valid' => true, 'mode' => 'random', 'value' => wayfarerNormalizeRuntimeNumber( (float)$rolled ), 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => '', ]; } function wayfarerApplyRuntimeOneTimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect[ 'target_scope' ] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect[ 'target_type' ] ?? '' ) ) ); $targetId = trim( (string)( $effect[ 'target_id' ] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection[ 'branch_index' ], 'applied' => false, 'error' => $selection[ 'error' ], ]; } if ( !$selection['matched'] ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection[ 'branch_index' ]; $operation = strtolower( trim( (string)( $branch[ 'operation' ] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; if ($operation === 'none') { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => null, 'rolled_value' => null, 'applied' => false, 'error' => '', ]; } $resolved = wayfarerRuntimeRollEffectValue( $branch ); if (!$resolved['valid']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => $resolved[ 'mode' ], 'rolled_value' => null, 'random_min' => $resolved[ 'min' ], 'random_max' => $resolved[ 'max' ], 'applied' => false, 'error' => $resolved[ 'error' ], ]; } $concreteEffect = [ 'target_scope' => $scope, 'target_type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'value' => $resolved[ 'value' ], ]; $report = wayfarerApplyRuntimeEffect( $runtimeState, $concreteEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; $report['value_mode'] = $resolved[ 'mode' ]; $report['rolled_value'] = $resolved[ 'value' ]; if ( $resolved['mode'] === 'random' ) { $report['random_min'] = $resolved[ 'min' ]; $report['random_max'] = $resolved[ 'max' ]; } return $report; } /* |-------------------------------------------------------------------------- | One-time Effect player feedback | WAYFARER_ONE_TIME_EFFECT_FEEDBACK_V70 |-------------------------------------------------------------------------- | | Change events are derived from the Effect report AFTER runtime mutation. | Therefore Random rolls and min/max/non-negative clamps are already reflected | in before/after, and the player sees the quantity that truly changed. | | This layer is intentionally called only by narrative one-time Effect paths. | Tick Effects remain silent. | */ function wayfarerRuntimeFeedbackFindTrait( array $config, string $targetId, string $collection = 'traits' ): ?array { $traits = isset($config[$collection]) && is_array($config[$collection]) ? $config[$collection] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } $id = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($id === $targetId) { return $trait; } } return null; } function wayfarerRuntimeFeedbackTraitIsPlayerFacing(array $trait): bool { $feedback = strtolower( trim( (string)( $trait['feedback'] ?? '' ) ) ); if ($feedback === 'hide') { return false; } if ($feedback === 'show') { return true; } $display = isset($trait['display']) && is_array($trait['display']) ? $trait['display'] : []; $surfaces = isset($display['surfaces']) && is_array($display['surfaces']) ? $display['surfaces'] : []; return !empty($surfaces); } function wayfarerRuntimeFeedbackTraitMetadata( array $trait, string $fallbackLabel, string $ownerLabel = '' ): ?array { if (!wayfarerRuntimeFeedbackTraitIsPlayerFacing($trait)) { return null; } $label = trim( (string)( $trait['name'] ?? $fallbackLabel ) ); if ($label === '') { $label = $fallbackLabel; } $type = strtolower( trim( (string)( $trait['type'] ?? 'number' ) ) ); if ($type === 'binary') { $type = 'boolean'; } return [ 'kind' => 'trait', 'label' => $label, 'owner_label' => $ownerLabel, 'data_type' => $type, ]; } function wayfarerRuntimeFeedbackTargetMetadata( PDO $pdo, int $gameId, array $runtimeState, array $report ): ?array { $scope = strtolower( trim( (string)( $report['scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $report['type'] ?? '' ) ) ); $targetId = trim( (string)( $report['target_id'] ?? '' ) ); if ( $scope === '' || $targetId === '' || $type !== 'trait' ) { return null; } /* Navigation is deliberately silent even for one-time Effects. */ if ($scope === 'navigation') { return null; } /* -------------------------------------------------------------- * Entity state: Quantity and Party Membership are intrinsically visible. * ----------------------------------------------------------- */ if ( preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityMatch ) === 1 ) { $entityId = (int)$entityMatch[1]; $stmt = $pdo->prepare( " SELECT name, entity_type, config FROM wf_entities WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $entityId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $entityName = trim( (string)( $row['name'] ?? ('Entity ' . $entityId) ) ); if ($entityName === '') { $entityName = 'Entity ' . $entityId; } $systemKey = strtolower( trim( (string)( $report['target_system_key'] ?? '' ) ) ); if ( $targetId === 'sys_item_quantity' || $systemKey === 'quantity' ) { return [ 'kind' => 'item_quantity', 'label' => $entityName, 'owner_label' => $entityName, 'data_type' => 'number', ]; } if ( $targetId === 'sys_character_party_member' || $systemKey === 'party_member' ) { return [ 'kind' => 'party_membership', 'label' => $entityName, 'owner_label' => $entityName, 'data_type' => 'boolean', ]; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, $entityName ) : null; } /* -------------------------------------------------------------- * Game Trait. * ----------------------------------------------------------- */ if ($scope === 'game') { $stmt = $pdo->prepare( 'SELECT config FROM wf_games WHERE id = ? LIMIT 1' ); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); if ($raw === false) { return null; } $config = wayfarerRuntimeDecodeTraitConfig($raw); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, 'Game' ) : null; } /* -------------------------------------------------------------- * Specific Region Trait, plus legacy current-Region compatibility. * ----------------------------------------------------------- */ $regionId = 0; if ( preg_match( '/^region:([1-9][0-9]*)$/', $scope, $regionMatch ) === 1 ) { $regionId = (int)$regionMatch[1]; } elseif ($scope === 'region') { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); } if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, trim((string)($row['name'] ?? 'Region')) ) : null; } /* -------------------------------------------------------------- * Location Trait. * ----------------------------------------------------------- */ if ( preg_match( '/^location:([1-9][0-9]*)$/', $scope, $locationMatch ) === 1 ) { $locationId = (int)$locationMatch[1]; $stmt = $pdo->prepare( " SELECT l.name, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE l.id = ? AND r.game_id = ? LIMIT 1 " ); $stmt->execute([ $locationId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, trim((string)($row['name'] ?? 'Location')) ) : null; } /* -------------------------------------------------------------- * Specific Player Character Trait. * ----------------------------------------------------------- */ if ( preg_match( '/^player:([0-9]+)$/', $scope, $playerMatch ) === 1 ) { $slot = (int)$playerMatch[1]; $stmt = $pdo->prepare( 'SELECT config FROM wf_games WHERE id = ? LIMIT 1' ); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); if ($raw === false) { return null; } $config = wayfarerRuntimeDecodeTraitConfig($raw); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId, 'player_traits' ); if (!is_array($trait)) { return null; } $playerName = trim( (string)( $runtimeState['players'][(string)$slot]['name'] ?? ('Player ' . ($slot + 1)) ) ); return wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, $playerName ); } return null; } function wayfarerRuntimeBuildEffectChangeEvents( PDO $pdo, int $gameId, array $runtimeState, array $reports ): array { $changes = []; foreach ($reports as $report) { if ( !is_array($report) || empty($report['applied']) ) { continue; } $metadata = wayfarerRuntimeFeedbackTargetMetadata( $pdo, $gameId, $runtimeState, $report ); if (!is_array($metadata)) { continue; } $before = $report['before'] ?? null; $after = $report['after'] ?? null; $kind = (string)($metadata['kind'] ?? 'trait'); $label = trim((string)($metadata['label'] ?? '')); if ($label === '') { continue; } if ($kind === 'party_membership') { if (!is_numeric($before) || !is_numeric($after)) { continue; } $beforeMember = (float)$before > 0; $afterMember = (float)$after > 0; if ($beforeMember === $afterMember) { continue; } $joined = $afterMember; $changes[] = [ 'kind' => 'party_membership', 'direction' => $joined ? 'gain' : 'loss', 'label' => $label, 'before' => $beforeMember ? 1 : 0, 'after' => $afterMember ? 1 : 0, 'delta' => $joined ? 1 : -1, 'text' => $label . ($joined ? ' joined your party.' : ' left your party.'), 'aria_text' => $label . ($joined ? ' joined your party.' : ' left your party.'), ]; continue; } $dataType = strtolower( trim((string)($metadata['data_type'] ?? 'number')) ); if ($dataType === 'boolean') { if (!is_numeric($before) || !is_numeric($after)) { continue; } $beforeBool = (float)$before > 0; $afterBool = (float)$after > 0; if ($beforeBool === $afterBool) { continue; } $changes[] = [ 'kind' => $kind, 'direction' => $afterBool ? 'gain' : 'loss', 'label' => $label, 'before' => $beforeBool ? 1 : 0, 'after' => $afterBool ? 1 : 0, 'delta' => $afterBool ? 1 : -1, 'text' => $label . ': ' . ($afterBool ? 'On' : 'Off'), 'aria_text' => $label . ' is now ' . ($afterBool ? 'on.' : 'off.'), ]; continue; } if (!is_numeric($before) || !is_numeric($after)) { continue; } $deltaFloat = (float)$after - (float)$before; if (abs($deltaFloat) < 0.000000000001) { continue; } $delta = wayfarerNormalizeRuntimeNumber($deltaFloat); $amount = wayfarerNormalizeRuntimeNumber(abs($deltaFloat)); $gain = $deltaFloat > 0; $text = ($gain ? '+' : '-') . (string)$amount . ' ' . $label; if ($kind === 'item_quantity') { $ariaText = ($gain ? 'Gained ' : 'Lost ') . (string)$amount . ' ' . $label . '.'; } else { $ariaText = $label . ($gain ? ' increased by ' : ' decreased by ') . (string)$amount . '.'; } $changes[] = [ 'kind' => $kind, 'direction' => $gain ? 'gain' : 'loss', 'label' => $label, 'before' => $before, 'after' => $after, 'delta' => $delta, 'text' => $text, 'aria_text' => $ariaText, ]; } return $changes; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECT_RUNTIME_V42 | | These are event Effects, not Tick Effects. The caller decides that a | Location has been triggered and invokes this function exactly once for that | trigger. Effects execute top-to-bottom against current runtime state. | */ function wayfarerApplyLocationEffects( PDO $pdo, int $gameId, array $location, array &$runtimeState ): array { $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeTraitConfig( $location['config'] ?? null ); $effects = $locationConfig['effects'] ?? []; if (!is_array($effects)) { $effects = []; } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false ) { $regionConfig = wayfarerRuntimeDecodeTraitConfig( $rawRegionConfig ); } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeOneTimeEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_location_effects'] = [ 'location_id' => $locationId, 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), 'reports' => $reports, /* WAYFARER_LOCATION_EFFECT_FEEDBACK_V70 */ 'changes' => wayfarerRuntimeBuildEffectChangeEvents( $pdo, $gameId, $runtimeState, $reports ), 'bound_repairs' => $boundRepairs, ]; return $reports; } /* |-------------------------------------------------------------------------- | Current Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECT_RUNTIME_V35 | | Linear Regions resolve a current Location by exact authored Position, | matching the player-scene resolver. Location Tick Effects run after Region | Tick Effects, so movement performed by the Region can cause arrival effects | at the newly reached Location on the same tick. | */ function wayfarerRuntimeFindCurrentLocation( PDO $pdo, int $gameId, array $runtimeState ): ?array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ( $gameId <= 0 || $regionId <= 0 ) { return null; } $position = $runtimeState['navigation']['position'] ?? null; if (!is_numeric($position)) { return null; } $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($locations as $location) { $config = []; if ( !empty( $location['config'] ) ) { $decoded = json_decode( (string)$location['config'], true ); if (is_array($decoded)) { $config = $decoded; } } if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } if ( (float)$config['position'] === (float)$position ) { $location['_runtime_config'] = $config; /* * WAYFARER_LOCATION_CONDITIONS_CURRENT_V45 */ if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } return $location; } } return null; } function wayfarerApplyCurrentLocationTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } /* * Being between authored Locations is normal. Record an empty result * rather than treating the absence of a Location as an error. */ if (!$location) { $runtimeState['runtime']['last_location_id'] = null; $runtimeState['runtime']['last_location_tick_effects'] = []; return []; } $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : []; $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); /* * Location effects may target Current Region Traits, so load the active * Region's Trait definitions rather than the Location's authored Traits. */ $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false && $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $locationEffects = $locationConfig['tick_effects'] ?? []; if (!is_array($locationEffects)) { $locationEffects = []; } $reports = []; foreach ( $locationEffects as $effect ) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } $runtimeState['runtime']['last_location_id'] = $locationId; $runtimeState['runtime']['last_location_tick_effects'] = $reports; $runtimeState['runtime']['last_location_trait_bound_repairs'] = $boundRepairs; return $reports; } function wayfarerApplyCurrentRegionTickEffects( PDO $pdo, array &$runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ($regionId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active navigation region.', ] ]; /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Reports are returned for the current request, not persisted. */ return $reports; } $stmt = $pdo->prepare( " SELECT id, game_id, config FROM wf_regions WHERE id = ? LIMIT 1 " ); $stmt->execute([ $regionId ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { $reports = [ [ 'applied' => false, 'error' => 'Active Region ' . $regionId . ' was not found.', ] ]; /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Reports are returned for the current request, not persisted. */ return $reports; } $config = []; if (!empty($region['config'])) { $decoded = json_decode( $region['config'], true ); if (is_array($decoded)) { $config = $decoded; } } /* WAYFARER_LINEAR_TRAVEL_TICK_PIPELINE_V72 */ $dedicatedTravel = wayfarerRuntimeDedicatedLinearTravel( $config ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, (int)( $region['game_id'] ?? 0 ), $config ); /* * Enforce the invariant before any branch condition is evaluated. * This also repairs state produced by older runtime versions. */ $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $regionId ); $effects = $config['tick_effects'] ?? []; if (!is_array($effects)) { $effects = []; } $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed tick effect.', ]; continue; } /* * WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 * * A dedicated travel Region owns movement through config.travel. * Ignore only Region Tick Effects that target Navigation Position; * every other recurring Region Effect continues normally. */ if ( $dedicatedTravel !== null && strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ) === 'navigation' && strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ) === 'position' && (string)( $effect['target_id'] ?? '' ) === 'position' ) { $reports[] = [ 'scope' => 'navigation', 'type' => 'position', 'target_id' => 'position', 'applied' => false, 'ignored' => true, 'reason' => 'Dedicated Linear Travel owns Region movement.', 'error' => '', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $regionId, $traitDefinitions ); } /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ if ($dedicatedTravel !== null) { $reports[] = wayfarerRuntimeApplyDedicatedLinearTravelMovement( $runtimeState, $dedicatedTravel ); } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Tick reports and bound-repair reports are immediate diagnostics only. */ return $reports; } ================================================================================ FILE: _update_backups/v73-20260928-161130/play/includes/runtime.php ================================================================================ $mode, 'starting_day' => max(1, (int)($raw['starting_day'] ?? 1)), 'ticks_per_day' => max(1, (int)($raw['ticks_per_day'] ?? 1)), 'starting_hour' => max(0, (int)($raw['starting_hour'] ?? 8)), 'ticks_per_hour' => max(1, (int)($raw['ticks_per_hour'] ?? 1)), 'hours_per_day' => max(1, (int)($raw['hours_per_day'] ?? 24)), ]; if ($config['starting_hour'] >= $config['hours_per_day']) { $config['starting_hour'] = $config['hours_per_day'] - 1; } return $config; } function wayfarerRuntimeInitializeClockFromConfig( array $gameConfig, array &$runtimeState ): void { if (!isset($runtimeState['runtime']) || !is_array($runtimeState['runtime'])) { $runtimeState['runtime'] = []; } $config = wayfarerRuntimeTimeConfig($gameConfig); if ($config['mode'] === 'none') { unset($runtimeState['runtime']['clock']); return; } $clock = [ 'mode' => $config['mode'], 'day' => $config['starting_day'], ]; if ($config['mode'] === 'days') { $clock['tick_in_day'] = 0; } else { $clock['hour'] = $config['starting_hour']; $clock['tick_in_hour'] = 0; } $runtimeState['runtime']['clock'] = $clock; } function wayfarerRuntimeLoadGameConfig(PDO $pdo, int $gameId): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare('SELECT config FROM wf_games WHERE id = ? LIMIT 1'); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); return wayfarerRuntimeDecodeConfig($raw === false ? null : $raw); } function wayfarerRuntimeEnsureClock( PDO $pdo, int $gameId, array &$runtimeState ): array { $gameConfig = wayfarerRuntimeLoadGameConfig($pdo, $gameId); $timeConfig = wayfarerRuntimeTimeConfig($gameConfig); $clock = $runtimeState['runtime']['clock'] ?? null; if ($timeConfig['mode'] === 'none') { unset($runtimeState['runtime']['clock']); return $timeConfig; } if (!is_array($clock) || (string)($clock['mode'] ?? '') !== $timeConfig['mode']) { wayfarerRuntimeInitializeClockFromConfig($gameConfig, $runtimeState); } return $timeConfig; } function wayfarerRuntimeAdvanceClock( PDO $pdo, int $gameId, array &$runtimeState ): void { $config = wayfarerRuntimeEnsureClock($pdo, $gameId, $runtimeState); if ($config['mode'] === 'none') { return; } $clock =& $runtimeState['runtime']['clock']; $clock['day'] = max(1, (int)($clock['day'] ?? $config['starting_day'])); if ($config['mode'] === 'days') { $clock['tick_in_day'] = max(0, (int)($clock['tick_in_day'] ?? 0)) + 1; if ($clock['tick_in_day'] >= $config['ticks_per_day']) { $clock['day']++; $clock['tick_in_day'] = 0; } return; } $clock['hour'] = max(0, (int)($clock['hour'] ?? $config['starting_hour'])); $clock['tick_in_hour'] = max(0, (int)($clock['tick_in_hour'] ?? 0)) + 1; if ($clock['tick_in_hour'] >= $config['ticks_per_hour']) { $clock['tick_in_hour'] = 0; $clock['hour']++; if ($clock['hour'] >= $config['hours_per_day']) { $clock['hour'] = 0; $clock['day']++; } } } function wayfarerRuntimeClockLabel(array $runtimeState, array $timeConfig): string { if ($timeConfig['mode'] === 'none') { return ''; } $clock = $runtimeState['runtime']['clock'] ?? []; $day = max(1, (int)($clock['day'] ?? $timeConfig['starting_day'])); if ($timeConfig['mode'] === 'days') { return 'Day ' . $day; } $hour = max(0, (int)($clock['hour'] ?? $timeConfig['starting_hour'])); if ($timeConfig['hours_per_day'] === 24) { $displayHour = $hour % 12; if ($displayHour === 0) { $displayHour = 12; } $suffix = $hour < 12 ? 'AM' : 'PM'; return 'Day ' . $day . ' · ' . $displayHour . ':00 ' . $suffix; } return 'Day ' . $day . ' · Hour ' . $hour; } /* * WAYFARER_BIDIRECTIONAL_TRAVEL_V71 * * Runtime direction is journey state, not Region authoring state. +1 follows * the Region's authored forward Tick Effect; -1 reverses it. Keeping the * authored Add/Subtract sign as the base preserves existing Regions while * allowing the same line to be traversed in either direction. */ function wayfarerRuntimeNavigationDirection(array $runtimeState): int { $direction = (int)( $runtimeState['navigation']['direction'] ?? 1 ); return $direction < 0 ? -1 : 1; } /* * WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 * * Returns canonical dedicated travel configuration, or null for Regions that * still use the legacy Position Tick Effect movement path. */ function wayfarerRuntimeDedicatedLinearTravel( array $regionConfig ): ?array { $travel = isset($regionConfig['travel']) && is_array($regionConfig['travel']) ? $regionConfig['travel'] : null; if ( $travel === null || empty($travel['enabled']) || !isset($travel['base_distance']) || !is_numeric($travel['base_distance']) || (float)$travel['base_distance'] <= 0 ) { return null; } return [ 'base_distance' => (float)$travel['base_distance'], 'allow_turnaround' => !empty( $travel['allow_turnaround'] ), ]; } function wayfarerRuntimeApplyDedicatedLinearTravelMovement( array &$runtimeState, array $travel ): array { if ( !isset($runtimeState['navigation']) || !is_array($runtimeState['navigation']) ) { $runtimeState['navigation'] = []; } $before = $runtimeState['navigation']['position'] ?? 0; if (!is_numeric($before)) { return [ 'scope' => 'navigation', 'type' => 'position', 'target_id' => 'position', 'applied' => false, 'error' => 'Navigation position must be numeric.', ]; } $distance = max( 0.0, (float)( $travel['base_distance'] ?? 0 ) ); $direction = wayfarerRuntimeNavigationDirection( $runtimeState ); $after = max( 0.0, (float)$before + ($distance * $direction) ); $after = wayfarerNormalizeRuntimeNumber( $after ); $runtimeState['navigation']['position'] = $after; return [ 'scope' => 'navigation', 'type' => 'position', 'target_id' => 'position', 'operation' => 'travel', 'amount' => wayfarerNormalizeRuntimeNumber( $distance ), 'direction' => $direction, 'before' => wayfarerNormalizeRuntimeNumber( (float)$before ), 'after' => $after, 'applied' => true, 'error' => '', ]; } function wayfarerRuntimeLinearTravelDirection( array $regionConfig, array $runtimeState ): int { $runtimeDirection = wayfarerRuntimeNavigationDirection( $runtimeState ); /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ if ( wayfarerRuntimeDedicatedLinearTravel( $regionConfig ) !== null ) { return $runtimeDirection; } $effects = isset($regionConfig['tick_effects']) && is_array($regionConfig['tick_effects']) ? $regionConfig['tick_effects'] : []; foreach ($effects as $effect) { if (!is_array($effect)) { continue; } if ( (string)($effect['target_scope'] ?? '') !== 'navigation' || (string)($effect['target_type'] ?? '') !== 'position' || (string)($effect['target_id'] ?? '') !== 'position' ) { continue; } $branches = wayfarerRuntimeTickEffectBranches($effect); foreach ($branches as $branch) { $operation = strtolower(trim((string)($branch['operation'] ?? ''))); if ($operation === 'subtract') { return -1 * $runtimeDirection; } if ($operation === 'add') { return 1 * $runtimeDirection; } } } return $runtimeDirection; } function wayfarerRuntimeBuildTravelStatus( PDO $pdo, int $gameId, array &$runtimeState ): array { $gameConfig = wayfarerRuntimeLoadGameConfig($pdo, $gameId); $timeConfig = wayfarerRuntimeEnsureClock($pdo, $gameId, $runtimeState); $result = [ 'time_label' => wayfarerRuntimeClockLabel($runtimeState, $timeConfig), 'region_name' => '', 'distance_label' => 'units', 'next_location_id' => 0, 'next_location_name' => '', 'distance_to_next' => null, 'next_label' => '', /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ 'dedicated_travel' => false, 'base_distance_per_tick' => null, 'allow_turnaround' => false, 'can_turn_around' => false, 'direction' => 1, ]; $regionId = (int)($runtimeState['navigation']['region_id'] ?? 0); $position = $runtimeState['navigation']['position'] ?? null; if ($regionId <= 0 || !is_numeric($position)) { return $result; } $stmt = $pdo->prepare( 'SELECT id, name, navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1' ); $stmt->execute([$regionId, $gameId]); $region = $stmt->fetch(PDO::FETCH_ASSOC); if (!$region) { return $result; } $result['region_name'] = trim((string)($region['name'] ?? '')); if (strtolower(trim((string)($region['navigation_type'] ?? ''))) !== 'linear') { return $result; } $regionConfig = wayfarerRuntimeDecodeConfig($region['config'] ?? null); $distanceLabel = trim((string)($regionConfig['distance_label'] ?? 'units')); $result['distance_label'] = $distanceLabel !== '' ? $distanceLabel : 'units'; $direction = wayfarerRuntimeLinearTravelDirection($regionConfig, $runtimeState); $current = (float)$position; /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ $dedicatedTravel = wayfarerRuntimeDedicatedLinearTravel( $regionConfig ); $result['direction'] = $direction; if ($dedicatedTravel !== null) { $result['dedicated_travel'] = true; $result['base_distance_per_tick'] = wayfarerNormalizeRuntimeNumber( (float)$dedicatedTravel[ 'base_distance' ] ); $result['allow_turnaround'] = !empty( $dedicatedTravel[ 'allow_turnaround' ] ); /* * Do not offer a reversal that would immediately point out of the * legal lower boundary. If already traveling toward Start at zero, * turning around toward End remains valid. */ $result['can_turn_around'] = $result['allow_turnaround'] && !( $current <= 0 && $direction > 0 ); } $locationStmt = $pdo->prepare( "SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id" ); $locationStmt->execute([$regionId]); $best = null; $bestDistance = null; foreach ($locationStmt->fetchAll(PDO::FETCH_ASSOC) as $location) { $locationConfig = wayfarerRuntimeDecodeConfig($location['config'] ?? null); if (!isset($locationConfig['position']) || !is_numeric($locationConfig['position'])) { continue; } $locationPosition = (float)$locationConfig['position']; $ahead = $direction < 0 ? $locationPosition < $current : $locationPosition > $current; if (!$ahead) { continue; } $location['_runtime_config'] = $locationConfig; if (!wayfarerRuntimeLocationConditionsPass($pdo, $gameId, $location, $runtimeState)) { continue; } $distance = abs($locationPosition - $current); if ($best === null || $distance < $bestDistance) { $best = $location; $bestDistance = $distance; } } if ($best !== null) { $distance = wayfarerNormalizeRuntimeNumber((float)$bestDistance); $name = trim((string)($best['name'] ?? 'Location')) ?: 'Location'; $result['next_location_id'] = (int)($best['id'] ?? 0); $result['next_location_name'] = $name; $result['distance_to_next'] = $distance; $result['next_label'] = $name . ' — ' . $distance . ' ' . $result['distance_label']; } return $result; } function wayfarerRuntimeActiveTraitDefinitions( array $config ): array { $traits = $config['traits'] ?? []; if (!is_array($traits)) { return []; } $result = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( array_key_exists( 'active', $trait ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $trait['_runtime_id'] = $traitId; $result[] = $trait; } return $result; } function wayfarerRuntimeInitialTraitValue( array $trait ) { if ( array_key_exists( 'initial', $trait ) ) { return $trait['initial']; } $type = strtolower( trim( (string)( $trait['type'] ?? '' ) ) ); if ( $type === 'number' || $type === 'binary' || $type === 'boolean' ) { return 0; } return null; } function wayfarerRuntimeInitialTraitState( array $config ): array { $state = []; foreach ( wayfarerRuntimeActiveTraitDefinitions( $config ) as $trait ) { $traitId = (string)$trait['_runtime_id']; $state[$traitId] = wayfarerRuntimeInitialTraitValue( $trait ); } return $state; } /* |-------------------------------------------------------------------------- | Persisted state for every Region |-------------------------------------------------------------------------- | WAYFARER_ALL_REGION_STATE_V46 | | Game-wide Conditions may inspect a Region that the player has never visited. | Therefore every authored Region needs persistent Trait state, not only the | current Region. | | This helper is additive/recovery-safe: | - existing runtime values are never overwritten; | - missing Regions or newly-added Traits receive their authored initial; | - stale historical values are not destructively removed here. | */ function wayfarerRuntimeEnsureAllRegionTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['regions']) || !is_array($runtimeState['regions']) ) { $runtimeState['regions'] = []; } $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? ORDER BY id " ); $stmt->execute([ $gameId ]); $regions = $stmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($regions as $region) { $regionId = (int)( $region['id'] ?? 0 ); if ($regionId <= 0) { continue; } $regionKey = (string)$regionId; $config = wayfarerRuntimeDecodeConfig( $region['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset( $runtimeState['regions'][$regionKey] ) || !is_array( $runtimeState['regions'][$regionKey] ) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset( $runtimeState['regions'][$regionKey]['traits'] ) || !is_array( $runtimeState['regions'][$regionKey]['traits'] ) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['regions'][$regionKey]['traits'] ) ) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $initialValue; } } } /* |-------------------------------------------------------------------------- | Persisted state for every Location | WAYFARER_ALL_LOCATION_STATE_V56 |-------------------------------------------------------------------------- | | Locations can own Traits, so universal Conditions/Effects need persistent | per-playthrough Location Trait state just like Regions. | */ function wayfarerRuntimeEnsureAllLocationTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['locations']) || !is_array($runtimeState['locations']) ) { $runtimeState['locations'] = []; } $stmt = $pdo->prepare( " SELECT l.id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $location ) { $locationId = (int)($location['id'] ?? 0); if ($locationId <= 0) { continue; } $locationKey = (string)$locationId; $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset($runtimeState['locations'][$locationKey]) || !is_array($runtimeState['locations'][$locationKey]) ) { $runtimeState['locations'][$locationKey] = []; } if ( !isset($runtimeState['locations'][$locationKey]['traits']) || !is_array($runtimeState['locations'][$locationKey]['traits']) ) { $runtimeState['locations'][$locationKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['locations'][$locationKey]['traits'] ) ) { continue; } $runtimeState['locations'][$locationKey]['traits'][$traitId] = $initialValue; } } } function wayfarerRuntimeLoadStartingRegion( PDO $pdo, int $gameId, ?array $gameConfig = null ): ?array { if ($gameId <= 0) { return null; } if ($gameConfig === null) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { return null; } $gameConfig = wayfarerRuntimeDecodeConfig( $rawConfig ); } $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT * FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $startingRegionId, $gameId, ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); return $region ?: null; } function wayfarerRuntimeInitializeGame( PDO $pdo, int $gameUserId, array $setupInput = [] ): array { if ($gameUserId <= 0) { throw new RuntimeException( 'A valid game-user ID is required to initialize the game.' ); } try { $pdo->beginTransaction(); /* * Lock the playthrough row so two initialize requests cannot create * competing starting states. */ $stmt = $pdo->prepare( " SELECT gu.id, gu.game_id, gu.user_id, gu.role, gu.state, g.config AS game_config, g.player_slots AS player_slots FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id WHERE gu.id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } /* * Initialization is intentionally one-way. To restart a development * game, clear wf_game_users.state to NULL first. */ if ( $row['state'] !== null && $row['state'] !== '' ) { throw new RuntimeException( 'This game-user already has runtime state.' ); } $gameId = (int)$row['game_id']; $gameConfig = wayfarerRuntimeDecodeConfig( $row['game_config'] ?? null ); $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { throw new RuntimeException( 'This Game does not have a Starting Region configured.' ); } $startingRegion = wayfarerRuntimeLoadStartingRegion( $pdo, $gameId, $gameConfig ); if (!$startingRegion) { throw new RuntimeException( 'The configured Starting Region does not exist for this Game.' ); } $regionConfig = wayfarerRuntimeDecodeConfig( $startingRegion['config'] ?? null ); $gameTraits = wayfarerRuntimeInitialTraitState( $gameConfig ); $regionTraits = wayfarerRuntimeInitialTraitState( $regionConfig ); $navigation = [ 'region_id' => (int)$startingRegion['id'], /* WAYFARER_BIDIRECTIONAL_TRAVEL_V71 */ 'direction' => 1, ]; /* * Linear travel currently begins at coordinate zero. * Other navigation modes will get their own authored start-state * rules when those modes are implemented. */ if ( strtolower( trim( (string)( $startingRegion['navigation_type'] ?? '' ) ) ) === 'linear' ) { $navigation['position'] = 0; } $regionKey = (string)$startingRegion['id']; $state = [ /* * WAYFARER_RUNTIME_SCHEMA_V55 * WAYFARER_BIDIRECTIONAL_TRAVEL_V71 * Version the persisted playthrough contract explicitly. */ 'schema_version' => 2, 'game' => [ 'traits' => $gameTraits, ], 'navigation' => $navigation, 'regions' => [ $regionKey => [ 'traits' => $regionTraits, ], ], 'runtime' => [ 'tick' => 0, ], ]; /* WAYFARER_INITIALIZE_GAME_CLOCK_V61 */ wayfarerRuntimeInitializeClockFromConfig( $gameConfig, $state ); /* * WAYFARER_ALL_REGION_STATE_V46 * * Global conditions can inspect Region state before that Region has * ever been visited. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $state ); /* WAYFARER_INITIALIZE_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_V49 * * Items and Characters receive canonical per-playthrough state before * any starting setup effects are applied. */ wayfarerRuntimeEnsureEntityState( $pdo, $gameId, $state ); /* * WAYFARER_APPLY_STARTING_CHOICES_V36 * * Validate all player-entered setup data before persisting anything. * Then apply the selected options' starting effects to the canonical * initial runtime state. */ $preparedSetup = wayfarerRuntimePrepareStartingSetup( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $setupInput ); /* * WAYFARER_PLAYER_TRAITS_RUNTIME_V52 * * Persist the validated player-character names before creating the * per-player Trait state. Each slot receives independent values. */ $state['setup'] = [ 'player_names' => $preparedSetup['player_names'], 'choices' => $preparedSetup['choices'], ]; wayfarerRuntimeEnsureGameTraitState( $gameConfig, $state ); wayfarerRuntimeEnsurePlayerState( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $state ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $startingEffectReports = []; foreach ( $preparedSetup['effects'] as $startingEffect ) { /* WAYFARER_STARTING_CHOICE_RANDOM_RUNTIME_V58 */ $resolvedStartingValue = wayfarerRuntimeRollEffectValue( $startingEffect ); if (!$resolvedStartingValue['valid']) { throw new RuntimeException( 'Unable to resolve a Starting Choice effect value: ' . $resolvedStartingValue['error'] ); } $concreteStartingEffect = $startingEffect; $concreteStartingEffect['value'] = $resolvedStartingValue['value']; $report = wayfarerApplyRuntimeEffect( $state, $concreteStartingEffect, (int)$startingRegion['id'], $traitDefinitions ); if ( !empty($report['error']) ) { throw new RuntimeException( 'Unable to apply a Starting Choice effect: ' . $report['error'] ); } $report['value_mode'] = $resolvedStartingValue['mode']; $report['rolled_value'] = $resolvedStartingValue['value']; if ($resolvedStartingValue['mode'] === 'random') { $report['random_min'] = $resolvedStartingValue['min']; $report['random_max'] = $resolvedStartingValue['max']; } $startingEffectReports[] = $report; } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * * Starting-effect reports are intentionally not persisted. The effects * themselves have already mutated canonical state. */ /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 * * A game that begins exactly on an active Location should present that * Location before ordinary Action Mode. */ $startingLocation = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $state ); if ($startingLocation) { /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Starting on a Location is a real Location trigger. Apply its * one-time Effects before opening Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, $gameId, $startingLocation, $state ); $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$startingLocation['id'], 'phase' => 'presentation', 'trigger' => 'initial', 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /* WAYFARER_GAME_ENDINGS_INITIAL_V45 */ wayfarerRuntimeEvaluateGameEndings( $pdo, $gameId, $state ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => $gameId, 'starting_region_id' => (int)$startingRegion['id'], 'state' => $state, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Resolve a game-user record for the tick prototype. * * Preferred: * explicit wf_game_users.id * * Temporary development fallback: * first wf_game_users record. * * The fallback exists only because Wayfarer has not yet wired the play * page to authentication / game selection. Once that exists, remove the * fallback and always pass the active game_user_id explicitly. */ function wayfarerRuntimeResolveGameUserId( PDO $pdo, ?int $requestedId = null ): int { if ($requestedId !== null && $requestedId > 0) { $stmt = $pdo->prepare( " SELECT id FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $requestedId ]); $id = $stmt->fetchColumn(); if ($id !== false) { return (int)$id; } throw new RuntimeException( 'The requested game-user record does not exist.' ); } $id = $pdo ->query( " SELECT id FROM wf_game_users ORDER BY id LIMIT 1 " ) ->fetchColumn(); if ($id === false) { throw new RuntimeException( 'No wf_game_users record exists.' ); } return (int)$id; } /** * Load one game-user runtime state. */ function wayfarerRuntimeLoad( PDO $pdo, int $gameUserId ): array { $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'state' => $state, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /** * Advance exactly one tick. * * This is intentionally the entire v14 game engine: * * state.runtime.tick += 1 * * The transaction and row lock are already in place so later work can add * movement/effects without changing the request architecture. */ /* |-------------------------------------------------------------------------- | Linear Location crossing | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | A tick may move farther than the distance to the next authored Location. | This resolver finds the FIRST active Location crossed in the direction of | travel. The movement is then clamped to that exact authored coordinate. | | Examples: | 45 -> 55, Location 50 => stop at 50 | 55 -> 45, Location 50 => stop at 50 | | The starting coordinate is intentionally exclusive so leaving a Location | does not immediately trigger that same Location again. | */ /* |-------------------------------------------------------------------------- | Location Conditions | WAYFARER_LOCATION_CONDITIONS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeLocationConditionsPass( PDO $pdo, int $gameId, array $location, array &$runtimeState ): bool { $config = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $conditions = $config['conditions'] ?? null; $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; return wayfarerConditionsPass( $conditions, $resolver ); } /* |-------------------------------------------------------------------------- | Game Endings | WAYFARER_GAME_ENDINGS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeEvaluateGameEndings( PDO $pdo, int $gameId, array &$runtimeState ): ?array { /* * WAYFARER_ALL_REGION_STATE_ENDINGS_V46 */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $runtimeState ); /* WAYFARER_RECOVER_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $runtimeState ); if ( isset( $runtimeState['runtime']['ending'] ) && is_array( $runtimeState['runtime']['ending'] ) ) { return $runtimeState['runtime']['ending']; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); $config = wayfarerRuntimeDecodeConfig( $raw === false ? null : $raw ); $endings = $config['endings'] ?? []; if (!is_array($endings)) { return null; } $ordered = []; foreach ( array_values($endings) as $index => $ending ) { if (!is_array($ending)) { continue; } $ending['_runtime_order'] = $index; $ordered[] = $ending; } usort( $ordered, function ( array $a, array $b ): int { $priorityCompare = (int)( $b['priority'] ?? 0 ) <=> (int)( $a['priority'] ?? 0 ); if ($priorityCompare !== 0) { return $priorityCompare; } return (int)( $a['_runtime_order'] ?? 0 ) <=> (int)( $b['_runtime_order'] ?? 0 ); } ); $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; foreach ($ordered as $ending) { if ( !wayfarerConditionsPass( $ending['conditions'] ?? null, $resolver ) ) { continue; } $terminal = [ 'id' => (string)( $ending['id'] ?? '' ), 'title' => (string)( $ending['title'] ?? 'The End' ), 'text' => (string)( $ending['text'] ?? '' ), 'image_media_id' => (int)( $ending['image_media_id'] ?? 0 ), 'priority' => (int)( $ending['priority'] ?? 0 ), 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), ]; if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['ending'] = $terminal; unset( $runtimeState['runtime']['presentation'] ); return $terminal; } return null; } function wayfarerRuntimeFindCrossedLocation( PDO $pdo, int $gameId, int $regionId, $fromPosition, $toPosition, array &$runtimeState ): ?array { /* * WAYFARER_CROSSED_LOCATION_STATE_FIX_V46_1 * * Location Conditions are evaluated against the live runtime state. */ if ( $gameId <= 0 || $regionId <= 0 || !is_numeric($fromPosition) || !is_numeric($toPosition) ) { return null; } $from = (float)$fromPosition; $to = (float)$toPosition; if ($from === $to) { return null; } /* * Crossing interception currently applies only to Linear Regions. */ $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); $forward = $to > $from; $candidate = null; $candidatePosition = null; foreach ($locations as $location) { $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $locationPosition = (float)$config['position']; /* * The starting point is exclusive; the destination is inclusive. */ $crossed = $forward ? ( $locationPosition > $from && $locationPosition <= $to ) : ( $locationPosition < $from && $locationPosition >= $to ); if (!$crossed) { continue; } $location['_runtime_config'] = $config; if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } if ($candidate === null) { $candidate = $location; $candidatePosition = $locationPosition; } else { $nearer = $forward ? ( $locationPosition < $candidatePosition ) : ( $locationPosition > $candidatePosition ); $samePositionLowerId = $locationPosition === $candidatePosition && (int)$location['id'] < (int)$candidate['id']; if ( $nearer || $samePositionLowerId ) { $candidate = $location; $candidatePosition = $locationPosition; } } } if ($candidate === null) { return null; } $candidate['_runtime_position'] = wayfarerNormalizeRuntimeNumber( (float)$candidatePosition ); $candidate['_runtime_config'] = wayfarerRuntimeDecodeConfig( $candidate['config'] ?? null ); return $candidate; } /* |-------------------------------------------------------------------------- | Player spatial context |-------------------------------------------------------------------------- | WAYFARER_PLAYER_CONTEXT_V46_1 | | This is the canonical player-facing spatial label for Action Mode: | | exact eligible Location -> Location name + Region parent context | otherwise -> Region name | | It is returned by runtime APIs so the browser does not keep displaying the | Location name from the page's original server render after movement. | */ function wayfarerRuntimePlayerContext( PDO $pdo, int $gameId, array $runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionName = ''; if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $rawRegionName = $stmt->fetchColumn(); if ($rawRegionName !== false) { $regionName = trim( (string)$rawRegionName ); } } $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); $locationId = 0; $locationName = ''; if ($location) { $locationId = (int)( $location['id'] ?? 0 ); $locationName = trim( (string)( $location['name'] ?? '' ) ); } $contextName = $locationName !== '' ? $locationName : ( $regionName !== '' ? $regionName : 'Actions' ); return [ 'region_id' => $regionId, 'region_name' => $regionName, 'location_id' => $locationId, 'location_name' => $locationName, 'context_name' => $contextName, 'parent_context' => $locationName !== '' ? $regionName : '', ]; } /* |-------------------------------------------------------------------------- | Presentation dismissal |-------------------------------------------------------------------------- | | Presentation is persisted runtime state. This function is intentionally | generic even though v40 renders only Location presentations; Storylets can | use the same mechanism later. | */ function wayfarerRuntimeDismissPresentation( PDO $pdo, int $gameUserId, string $expectedType = '', int $expectedId = 0 ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); $presentation = $state['runtime']['presentation'] ?? null; if ( !is_array($presentation) || empty($presentation['type']) || empty($presentation['id']) ) { throw new RuntimeException( 'There is no active presentation to continue.' ); } if ( $expectedType !== '' && (string)$presentation['type'] !== $expectedType ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } if ( $expectedId > 0 && (int)$presentation['id'] !== $expectedId ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } /* WAYFARER_STORYLET_BRANCH_DISMISS_GUARD_V59 */ if ( (string)($presentation['type'] ?? '') === 'storylet' ) { $eligibleBranches = wayfarerRuntimeEligibleStoryletBranches( $pdo, (int)$row['game_id'], (int)($presentation['id'] ?? 0), $state ); if ($eligibleBranches) { throw new RuntimeException( 'Choose an available Storylet Branch before continuing.' ); } } $state['runtime']['last_presentation'] = $presentation; unset( $state['runtime']['presentation'] ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'dismissed' => $presentation, /* WAYFARER_STORYLET_ACTIONS_AFTER_PRESENTATION_V59 */ 'actions' => wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$row['game_id'], $state ), ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Advance exactly one tick. * * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ function wayfarerRuntimeAdvanceTick( PDO $pdo, int $gameUserId, ?int $requestedTravelDirection = null ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } /* WAYFARER_BIDIRECTIONAL_TRAVEL_V71 */ if ( !isset($state['navigation']) || !is_array($state['navigation']) ) { $state['navigation'] = []; } if ( $requestedTravelDirection !== null && !in_array( $requestedTravelDirection, [-1, 1], true ) ) { throw new RuntimeException( 'Travel direction must be -1 or 1.' ); } $state['navigation']['direction'] = $requestedTravelDirection !== null ? $requestedTravelDirection : wayfarerRuntimeNavigationDirection( $state ); $state['schema_version'] = max( 2, (int)( $state['schema_version'] ?? 1 ) ); /* * WAYFARER_GAME_ENDINGS_TERMINAL_V45 * * Endings are terminal runtime state. */ if ( isset( $state['runtime']['ending'] ) && is_array( $state['runtime']['ending'] ) ) { throw new RuntimeException( 'This game has ended.' ); } /* * Narrative presentation is a true runtime pause, not merely a hidden * browser control. Even Author Mode cannot advance ticks through it. */ if ( isset( $state['runtime']['presentation'] ) && is_array( $state['runtime']['presentation'] ) ) { throw new RuntimeException( 'Continue the current presentation before advancing time.' ); } /* * WAYFARER_ALL_REGION_STATE_TICK_V46 * * Recovery path for playthroughs initialized before v46, and for * Regions/Traits authored after a playthrough began. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_LOCATION_STATE_TICK_RECOVERY_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, (int)$row['game_id'], $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_TICK_V49 * * Recovery path for playthroughs created before V49 and for Entities * or Traits authored after play began. */ wayfarerRuntimeEnsureEntityState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_PLAYER_TRAITS_TICK_RECOVERY_V52 */ wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$row['game_id'], $state ); $regionIdBeforeTick = (int)( $state['navigation']['region_id'] ?? 0 ); $positionBeforeTick = $state['navigation']['position'] ?? null; $currentTick = (int)( $state['runtime']['tick'] ?? 0 ); $state['runtime']['tick'] = $currentTick + 1; /* WAYFARER_ADVANCE_GAME_CLOCK_V61 */ wayfarerRuntimeAdvanceClock( $pdo, (int)$row['game_id'], $state ); /* * Global Game Tick Effects run first. */ wayfarerApplyGameTickEffects( $pdo, (int)$row['game_id'], $state ); /* * Current Region Tick Effects then perform Region-level movement and * state changes. */ wayfarerApplyCurrentRegionTickEffects( $pdo, $state ); /* * Resolve whether the completed movement would have crossed an active * Location. If so, stop at the nearest crossed Location rather than * accepting the overshot position. */ $regionIdAfterMovement = (int)( $state['navigation']['region_id'] ?? 0 ); $positionAfterMovement = $state['navigation']['position'] ?? null; $crossedLocation = null; /* * We only interpret movement as a continuous path while the player * remains in the same Region. Region transitions will later get their * own explicit navigation semantics. */ if ( $regionIdBeforeTick > 0 && $regionIdBeforeTick === $regionIdAfterMovement ) { $crossedLocation = wayfarerRuntimeFindCrossedLocation( $pdo, (int)$row['game_id'], $regionIdAfterMovement, $positionBeforeTick, $positionAfterMovement, $state ); } $arrivalPosition = null; if ($crossedLocation) { $arrivalPosition = $crossedLocation[ '_runtime_position' ]; $state['navigation']['position'] = $arrivalPosition; /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Event Effects execute once at the trigger boundary. They run * before the Location's Tick Effects and before Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, (int)$row['game_id'], $crossedLocation, $state ); } /* * WAYFARER_LOCATION_EFFECTS_ONLY_RUNTIME_V43 * * Locations no longer own Tick Effects. Game and Region Tick Effects * have already advanced the simulation; a crossed Location contributes * its one-time Effects and then interrupts with Presentation Mode. */ if ($crossedLocation) { $state['navigation']['position'] = $arrivalPosition; $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$crossedLocation['id'], 'phase' => 'presentation', 'trigger' => 'arrival', 'tick' => (int)$state['runtime']['tick'], ]; $state['runtime']['last_location_arrival'] = [ 'location_id' => (int)$crossedLocation['id'], 'region_id' => $regionIdAfterMovement, 'position' => $arrivalPosition, 'tick' => (int)$state['runtime']['tick'], ]; } /* WAYFARER_GAME_ENDINGS_AFTER_TICK_V45 */ $ending = wayfarerRuntimeEvaluateGameEndings( $pdo, (int)$row['game_id'], $state ); $state['runtime']['last_tick_at'] = gmdate('c'); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId ]); $pdo->commit(); $presentation = $state['runtime']['presentation'] ?? null; $pausedForPresentation = is_array($presentation) && !empty($presentation['type']) && !empty($presentation['id']); $ending = $state['runtime']['ending'] ?? null; $ended = is_array($ending) && !empty($ending['id']); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'tick' => (int)$state['runtime']['tick'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'pause' => $pausedForPresentation || $ended, 'pause_reason' => $ended ? 'Game ended.' : ( $pausedForPresentation ? 'Location reached.' : '' ), 'presentation' => $pausedForPresentation ? $presentation : null, 'ending' => $ended ? $ending : null, /* WAYFARER_STORYLET_ACTIONS_TICK_API_V59 */ 'actions' => !$pausedForPresentation && !$ended ? wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$row['game_id'], $state ) : [], /* WAYFARER_TRAVEL_TIME_HUD_V61 */ 'travel' => wayfarerRuntimeBuildTravelStatus( $pdo, (int)$row['game_id'], $state ), ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: _update_backups/v74-20260928-161448/assets/js/game-travel.js ================================================================================ /* * WAYFARER_GAME_TRAVEL_MODIFIERS_JS_V73 * Dynamic Game Settings Travel Modifier cards. */ (function () { 'use strict'; const host = document.querySelector( '[data-game-travel-modifiers]' ); if (!host) { return; } const list = host.querySelector( '[data-game-travel-modifier-list]' ); const addButton = host.querySelector( '[data-add-game-travel-modifier]' ); const targetJson = host.querySelector( '[data-game-travel-targets-json]' ); if (!list || !addButton) { return; } let targets = {}; if (targetJson) { try { targets = JSON.parse( targetJson.textContent || '{}' ); } catch (error) { targets = {}; } } function nextIndex() { const current = Number( host.dataset.nextIndex || 0 ); const index = Number.isFinite(current) ? current : 0; host.dataset.nextIndex = String(index + 1); return index; } function refreshLegend(card) { const legend = card.querySelector( '[data-game-travel-modifier-legend]' ); const name = card.querySelector( '[data-game-travel-modifier-name]' ); if (!legend || !name) { return; } const value = String( name.value || '' ).trim(); legend.textContent = value !== '' ? value : 'Travel Modifier'; } host.addEventListener( 'input', function (event) { const name = event.target.closest( '[data-game-travel-modifier-name]' ); if (!name) { return; } const card = name.closest( '[data-game-travel-modifier]' ); if (card) { refreshLegend(card); } } ); host.addEventListener( 'click', function (event) { const removeButton = event.target.closest( '[data-remove-game-travel-modifier]' ); if (!removeButton) { return; } const card = removeButton.closest( '[data-game-travel-modifier]' ); if ( card && window.confirm( 'Remove this Travel Modifier?' ) ) { card.remove(); } } ); addButton.addEventListener( 'click', function () { const index = nextIndex(); const card = document.createElement( 'fieldset' ); card.dataset.gameTravelModifier = '1'; card.dataset.travelModifierIndex = String(index); card.innerHTML = 'Travel Modifier' + '' + '

' + '


Applied to the current Region\'s Base Distance Per Tick.

' + '

Apply when

' + '

No Conditions means this modifier always applies during dedicated Linear Travel.

' + '
' + '

'; const conditionEditor = card.querySelector( '[data-condition-editor]' ); if ( conditionEditor && typeof window.wayfarerConditionCreateGroup === 'function' ) { conditionEditor.appendChild( window.wayfarerConditionCreateGroup( 'travel_modifiers[' + index + '][conditions]', targets, null, true ) ); if ( typeof window.wayfarerConditionInitializeEditor === 'function' ) { window.wayfarerConditionInitializeEditor( conditionEditor, targets ); } } list.appendChild( card ); } ); })(); ================================================================================ FILE: _update_backups/v74-20260928-161448/includes/gameplay/travel-modifiers.php ================================================================================ $postedModifier ) { if (!is_array($postedModifier)) { continue; } $id = trim( (string)( $postedModifier['id'] ?? '' ) ); if ($id === '') { $id = wayfarerTravelModifierMakeId(); } $name = trim( (string)( $postedModifier['name'] ?? '' ) ); $operation = strtolower( trim( (string)( $postedModifier['operation'] ?? 'add' ) ) ); $valueRaw = trim( (string)( $postedModifier['value'] ?? '' ) ); $postedConditions = $postedModifier['conditions'] ?? null; $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, $allowedTargets ); $conditions = $conditionResult['node']; /* * A completely blank newly-added row is ignored. An empty Conditions * group is valid and means the modifier always applies. */ if ( $name === '' && $valueRaw === '' && $conditions === null && $conditionResult['error'] === '' ) { continue; } $editorValue = is_numeric($valueRaw) ? wayfarerTravelModifierNormalizeNumber( (float)$valueRaw ) : $valueRaw; $editorModifier = [ 'id' => $id, 'name' => $name, 'operation' => $operation, 'value' => $editorValue, 'conditions' => $conditions, ]; $editorModifiers[] = $editorModifier; $label = $name !== '' ? 'Travel Modifier "' . $name . '"' : 'Travel Modifier ' . ($index + 1); if ( $conditionResult['error'] !== '' && $firstError === '' ) { $firstError = $label . ': ' . $conditionResult['error']; continue; } if ( $name === '' && $firstError === '' ) { $firstError = 'Each Travel Modifier must have a Name.'; continue; } if ( !in_array( $operation, ['add', 'subtract'], true ) && $firstError === '' ) { $firstError = $label . ' must use Add or Subtract.'; continue; } if ( !is_numeric($valueRaw) || (float)$valueRaw <= 0 ) { if ($firstError === '') { $firstError = $label . ' must use an Amount greater than zero.'; } continue; } $value = wayfarerTravelModifierNormalizeNumber( (float)$valueRaw ); $modifiers[] = [ 'id' => $id, 'name' => $name, 'operation' => $operation, 'value' => $value, 'conditions' => $conditions, ]; } return [ 'modifiers' => $firstError === '' ? $modifiers : [], 'editor_modifiers' => $editorModifiers, 'error' => $firstError, ]; } function wayfarerRenderGameTravelModifiersEditor( array $modifiers, array $conditionTargets ): void { $modifiers = array_values( $modifiers ); ?>

Travel

Global travel rules follow the party between Regions. Matching modifiers adjust a Region's dedicated Base Distance Per Tick whenever a normal travel tick moves the player.

Example: if a Region moves 10 units per tick and Mara's Cart adds 5 while Mara is in the party, that travel tick moves 15 units. These modifiers are reevaluated every travel tick and do not produce normal Effect feedback.

$modifier ): ?>


Applied to the current Region's Base Distance Per Tick.

Apply when

No Conditions means this modifier always applies during dedicated Linear Travel.

$target) { if (!is_array($target)) { continue; } if ( strtolower(trim((string)($target['data_type'] ?? ''))) !== 'number' || strtolower(trim((string)($target['target_type'] ?? ''))) !== 'trait' ) { continue; } $scope = trim((string)($target['target_scope'] ?? $target['scope'] ?? '')); $targetId = trim((string)($target['target_id'] ?? '')); /* * V94 is deliberately direct-state only. Party aggregation is a useful * future source, but it needs explicit average/min/max semantics first. */ if ( $targetId === '' || !( $scope === 'game' || preg_match('/^(region|location|entity):[1-9][0-9]*$/', $scope) === 1 ) ) { continue; } $label = trim((string)($target['label'] ?? $key)); $semantic = trim((string)($target['semantic_type'] ?? 'trait')); $hudLabel = $label; if (strpos($label, ' → ') !== false) { $parts = explode(' → ', $label); if ($semantic === 'item_quantity' && count($parts) >= 2) { $hudLabel = trim((string)$parts[count($parts) - 2]); } else { $hudLabel = trim((string)end($parts)); } } $min = isset($target['min']) && is_numeric($target['min']) ? (float)$target['min'] : null; $max = isset($target['max']) && is_numeric($target['max']) ? (float)$target['max'] : null; $sources[(string)$key] = [ 'key' => (string)$key, 'scope' => $scope, 'target_id' => $targetId, 'label' => $label !== '' ? $label : (string)$key, 'hud_label' => $hudLabel !== '' ? $hudLabel : ($label !== '' ? $label : (string)$key), 'min' => $min, 'max' => $max, 'bounded' => $min !== null && $max !== null && $max > $min, 'semantic_type' => $semantic, ]; } uasort( $sources, static function (array $a, array $b): int { return strcasecmp((string)$a['label'], (string)$b['label']); } ); return $sources; } function gameplayHudLegacyRegionComponents( PDO $pdo, int $gameId ): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? ORDER BY id " ); $stmt->execute([$gameId]); $merged = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $regionId = (int)($row['id'] ?? 0); if ($regionId <= 0) { continue; } $config = gameplayRuntimeStateDecodeConfig($row['config'] ?? null); $hud = isset($config['hud']) && is_array($config['hud']) ? $config['hud'] : []; if (empty($hud['enabled']) || empty($hud['components']) || !is_array($hud['components'])) { continue; } foreach ($hud['components'] as $component) { if (!is_array($component)) { continue; } $legacyScope = trim((string)($component['source_scope'] ?? '')); $sourceId = trim((string)($component['source_id'] ?? '')); if ($sourceId === '') { continue; } $scope = $legacyScope === 'region' ? 'region:' . $regionId : $legacyScope; if ($scope !== 'game' && preg_match('/^region:[1-9][0-9]*$/', $scope) !== 1) { continue; } $sourceKey = $scope . ':trait:' . $sourceId; $label = trim((string)($component['label'] ?? '')); $type = strtolower(trim((string)($component['type'] ?? 'progress_bar'))); if (!in_array($type, ['number', 'progress_bar'], true)) { $type = 'progress_bar'; } $mergeKey = implode('|', [ $type, $sourceKey, $label, !empty($component['show_value']) ? '1' : '0', ]); if (!isset($merged[$mergeKey])) { $merged[$mergeKey] = [ 'id' => trim((string)($component['id'] ?? '')), 'type' => $type, 'source' => $sourceKey, 'label' => $label, 'show_value' => !empty($component['show_value']), /* Legacy Region HUD was persistent outside Storylets. */ 'show_in' => 'both', 'regions' => [], ]; } $merged[$mergeKey]['regions'][] = $regionId; $merged[$mergeKey]['regions'] = array_values(array_unique(array_map('intval', $merged[$mergeKey]['regions']))); } } return array_values($merged); } function gameplayBuildGameHudFromPost( array $postedComponents, array $sources, array $validRegionIds ): array { $components = []; $validRegionIds = array_values(array_unique(array_map('intval', $validRegionIds))); $validRegionMap = array_fill_keys($validRegionIds, true); foreach (array_values($postedComponents) as $rowIndex => $posted) { if (!is_array($posted)) { continue; } $type = strtolower(trim((string)($posted['type'] ?? 'number'))); if (!in_array($type, ['number', 'progress_bar'], true)) { return [ 'components' => [], 'editor_components' => array_values($postedComponents), 'error' => 'HUD item ' . ($rowIndex + 1) . ' has an unsupported display type.', ]; } /* WAYFARER_HUD_SURFACES_V95 */ $showIn = strtolower(trim((string)($posted['show_in'] ?? 'travel'))); if (!in_array($showIn, ['travel', 'action', 'both'], true)) { return [ 'components' => [], 'editor_components' => array_values($postedComponents), 'error' => 'HUD item ' . ($rowIndex + 1) . ' has an unsupported Show In value.', ]; } $sourceKey = trim((string)($posted['source'] ?? '')); $source = $sources[$sourceKey] ?? null; if (!is_array($source)) { return [ 'components' => [], 'editor_components' => array_values($postedComponents), 'error' => 'HUD item ' . ($rowIndex + 1) . ' must select a numeric source.', ]; } if ($type === 'progress_bar' && empty($source['bounded'])) { return [ 'components' => [], 'editor_components' => array_values($postedComponents), 'error' => 'HUD item ' . ($rowIndex + 1) . ' uses a Progress Bar, but its source does not have both a numeric Minimum and Maximum.', ]; } $regions = []; foreach ((array)($posted['regions'] ?? []) as $regionId) { $regionId = (int)$regionId; if ($regionId > 0 && isset($validRegionMap[$regionId])) { $regions[] = $regionId; } } $regions = array_values(array_unique($regions)); $id = trim((string)($posted['id'] ?? '')); if ($id === '') { $id = 'hud_' . bin2hex(random_bytes(8)); } $components[] = [ 'id' => $id, 'type' => $type, 'source' => $sourceKey, 'label' => trim((string)($posted['label'] ?? '')), 'show_value' => $type === 'number' ? true : !empty($posted['show_value']), 'show_in' => $showIn, /* Empty means every Region. */ 'regions' => $regions, ]; } return [ 'components' => $components, 'editor_components' => $components, 'error' => '', ]; } ================================================================================ FILE: _update_backups/v97-20260930-181941/play/assets/play.js ================================================================================ /* |-------------------------------------------------------------------------- | PLAYER RUNTIME CONTROLLER | WAYFARER_ACTION_SCREEN_V39 | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | Runtime modes currently exposed to the player: | | presentation -> Action -> running navigation -> presentation/Action | | Presentation is persisted by PHP. Automatic tick advancement cannot continue | while a presentation is active. | */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; if (!config) { return; } const body = document.body; const tickValue = document.getElementById( 'wayfarer-tick-value' ); const statusValue = document.getElementById( 'wayfarer-tick-status' ); const message = document.getElementById( 'wayfarer-tick-message' ); const onceButton = document.getElementById( 'wayfarer-tick-once' ); const autoButton = document.getElementById( 'wayfarer-tick-auto' ); const authorPauseButton = document.getElementById( 'wayfarer-tick-pause' ); const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); /* WAYFARER_HUD_SURFACES_LIVE_V95 */ const playerHuds = Array.from( document.querySelectorAll( '[data-wayfarer-hud]' ) ); /* WAYFARER_TRAVEL_TIME_HUD_V61 */ const travelTime = document.querySelector( '[data-wayfarer-travel-time]' ); const travelTimeSeparator = document.querySelector( '[data-wayfarer-travel-time-separator]' ); const travelNext = document.querySelector( '[data-wayfarer-travel-next]' ); const continueTravelButton = document.querySelector( '[data-wayfarer-continue-travel]' ); const playerPauseButton = document.querySelector( '[data-wayfarer-pause-travel]' ); /* WAYFARER_PLAYER_TURN_AROUND_UI_V72 */ const turnAroundButton = document.querySelector( '[data-wayfarer-turn-around]' ); /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Action Mode owns one physical card. Inventory, Status, and future * utility/context views temporarily replace the normal action heading/list * inside that card without becoming persisted runtime modes. */ const actionHeading = actionScreen ? actionScreen.querySelector( '.wayfarer-action-heading' ) : null; const actionList = actionScreen ? actionScreen.querySelector( '[data-wayfarer-action-list]' ) : null; const actionSubviewPanels = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ) : []; const actionViewOpeners = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-open-action-view]' ) ) : []; const actionViewClosers = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-close-action-view]' ) ) : []; /* * Persisted narrative presentation. */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); const presentationContinueButton = presentationScreen ? presentationScreen.querySelector( '[data-wayfarer-presentation-continue]' ) : null; let running = false; let requestInFlight = false; let presentationRequestInFlight = false; let storyletRequestInFlight = false; let timeoutId = null; let reloadWhenIdle = false; /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ let requestedTravelDirection = Number( config.initialDirection ) < 0 ? -1 : 1; let presentationActive = Boolean( presentationScreen && !presentationScreen.hidden ); function setMessage( text, isError = false ) { if (!message) { return; } message.textContent = text; message.style.color = isError ? 'red' : ''; } function emitModeEvent() { let mode = 'paused'; if (presentationActive) { mode = 'presentation'; } else if (running) { mode = 'running'; } window.dispatchEvent( new CustomEvent( 'wayfarer:runtime-mode', { detail: { mode: mode } } ) ); /* * Keep the v39 event names available for existing development hooks. */ window.dispatchEvent( new CustomEvent( running ? 'wayfarer:runtime-running' : 'wayfarer:runtime-paused', { detail: { mode: mode } } ) ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Generic in-card view switcher. Future Map / Shop / other views can use * the same data-wayfarer-open-action-view + data-wayfarer-action-view * contract without adding another page-level layout system. */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Resolve available views at the moment navigation occurs instead of * relying on the V48 page-load snapshot. This supports nested Entity * detail screens and future context views added to the same Action card. */ function setActionView(viewName = 'actions') { if (!actionScreen) { return; } const requested = String( viewName || 'actions' ); const showActions = requested === 'actions'; const panels = Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ); let matched = showActions; panels.forEach( function (panel) { const active = panel.dataset .wayfarerActionView === requested; panel.hidden = !active; if (active) { matched = true; } } ); /* * Fail safely if a malformed/removed view name is requested. */ if (!matched) { panels.forEach( function (panel) { panel.hidden = true; } ); if (actionHeading) { actionHeading.hidden = false; } if (actionList) { actionList.hidden = false; } return; } if (actionHeading) { actionHeading.hidden = !showActions; } if (actionList) { actionList.hidden = !showActions; } } function renderControls() { if (statusValue) { statusValue.textContent = presentationActive ? 'Presentation' : ( running ? 'Running' : 'Paused' ); } if (onceButton) { onceButton.disabled = running || requestInFlight || presentationActive; } if (autoButton) { autoButton.disabled = running || requestInFlight || presentationActive; } if (authorPauseButton) { authorPauseButton.disabled = !running; } if (continueTravelButton) { continueTravelButton.disabled = running || requestInFlight || presentationActive; } document .querySelectorAll( '[data-wayfarer-storylet-action-id], ' + '[data-wayfarer-storylet-branch-id]' ) .forEach( function (button) { button.disabled = requestInFlight || presentationRequestInFlight || storyletRequestInFlight || ( button.hasAttribute( 'data-wayfarer-storylet-action-id' ) && (running || presentationActive) ); } ); if (playerPauseButton) { playerPauseButton.disabled = !running; } /* WAYFARER_PLAYER_TURN_AROUND_UI_V72 */ if (turnAroundButton) { turnAroundButton.disabled = running || requestInFlight || presentationActive; } if (actionScreen) { actionScreen.hidden = running || presentationActive; } if (travelControls) { travelControls.hidden = !running || presentationActive; } /* WAYFARER_HUD_SURFACE_VISIBILITY_V95 */ playerHuds.forEach(function (hud) { const surface = String( hud.dataset.wayfarerHudSurface || 'travel' ); if (surface === 'action') { hud.hidden = presentationActive || running; return; } hud.hidden = presentationActive || !running; }); if (presentationScreen) { presentationScreen.hidden = !presentationActive; presentationScreen.setAttribute( 'aria-hidden', presentationActive ? 'false' : 'true' ); } body.classList.toggle( 'wayfarer-runtime-running', running ); body.classList.toggle( 'wayfarer-runtime-paused', !running && !presentationActive ); body.classList.toggle( 'wayfarer-runtime-presentation', presentationActive ); } /* * WAYFARER_LIVE_ACTION_CONTEXT_V46_1 * * Action Mode must reflect the current runtime position, not the page's * original server-rendered context. */ function renderPlayerContext(context) { if ( !context || typeof context !== 'object' ) { return; } const heading = document.querySelector( '[data-wayfarer-action-context-name]' ); const parent = document.querySelector( '[data-wayfarer-action-parent-context]' ); if (heading) { const name = String( context.context_name || 'Actions' ).trim(); heading.textContent = name || 'Actions'; } if (parent) { const parentText = String( context.parent_context || '' ).trim(); parent.textContent = parentText; parent.hidden = parentText === ''; } } /* WAYFARER_TRAVEL_TIME_HUD_RENDER_V61 */ function renderTravelStatus(travel) { if (!travel || typeof travel !== 'object') { return; } const timeLabel = String(travel.time_label || '').trim(); if (travelTime) { travelTime.textContent = timeLabel; travelTime.hidden = timeLabel === ''; } if (travelTimeSeparator) { travelTimeSeparator.hidden = timeLabel === ''; } const nextLabel = String(travel.next_label || '').trim(); if (travelNext) { travelNext.textContent = nextLabel === '' ? '' : 'Next: ' + nextLabel; travelNext.hidden = nextLabel === ''; } } function displayRuntimeValue(value) { if (typeof value === 'boolean') { return value ? 'True' : 'False'; } if ( value === null || value === undefined ) { return 'null'; } if (typeof value === 'object') { return JSON.stringify( value ); } return String( value ); } /* * WAYFARER_STORYLET_ACTIONS_LIVE_V59 * * Tick/presentation APIs return the currently eligible root Manual * Storylets so Action Mode does not depend on the page-load snapshot. */ function renderStoryletActions(actions) { if (!actionScreen) { return; } const host = actionScreen.querySelector( '[data-wayfarer-storylet-actions]' ); if (!host) { return; } const normalized = Array.isArray(actions) ? actions : []; host.innerHTML = ''; normalized.forEach( function (action) { const id = Number( action && action.id ? action.id : 0 ); if (id <= 0) { return; } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-action-button'; button.dataset.wayfarerStoryletActionId = String(id); button.textContent = String( action.label || action.name || 'Storylet' ); host.appendChild( button ); } ); const empty = actionScreen.querySelector( '[data-wayfarer-action-empty]' ); if (empty) { const canTravel = actionScreen.dataset.wayfarerCanTravel === '1'; empty.hidden = canTravel || host.children.length > 0; } renderControls(); } /* * WAYFARER_GAME_TRAVEL_HUD_LIVE_V94 * * HUD components carry a direct runtime Trait address. Tick responses * already include canonical state, so Number and Progress Bar values can * update in place without a separate HUD endpoint. */ function resolveHudTraits(state, scope) { if (!state || typeof state !== 'object') { return null; } if (scope === 'game') { return state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : null; } const match = String(scope || '').match(/^(region|location|entity):([1-9][0-9]*)$/); if (!match) { return null; } const bucket = match[1] === 'region' ? 'regions' : (match[1] === 'location' ? 'locations' : 'entities'); const key = match[2]; return state[bucket] && state[bucket][key] && state[bucket][key].traits && typeof state[bucket][key].traits === 'object' ? state[bucket][key].traits : null; } function formatHudNumber(value) { const number = Number(value); if (!Number.isFinite(number)) { return String(value ?? ''); } return Number.isInteger(number) ? String(number) : String(Math.round(number * 100) / 100); } function renderHudState(state) { if (!playerHuds.length || !state || typeof state !== 'object') { return; } playerHuds.forEach(function (playerHud) { playerHud .querySelectorAll('[data-wayfarer-hud-component]') .forEach(function (component) { const type = String(component.dataset.wayfarerHudType || 'number'); const scope = String(component.dataset.wayfarerHudScope || ''); const sourceId = String(component.dataset.wayfarerHudSourceId || ''); const traits = resolveHudTraits(state, scope); if ( !traits || !Object.prototype.hasOwnProperty.call(traits, sourceId) ) { return; } const value = Number(traits[sourceId]); if (!Number.isFinite(value)) { return; } const valueNode = component.querySelector('[data-wayfarer-hud-value]'); if (valueNode) { valueNode.textContent = formatHudNumber(value); } if (type !== 'progress_bar') { return; } const min = Number(component.dataset.wayfarerHudMin); const max = Number(component.dataset.wayfarerHudMax); if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) { return; } const percent = Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)); const fill = component.querySelector('[data-wayfarer-hud-fill]'); if (fill) { fill.style.width = percent + '%'; } const meter = component.querySelector('[data-wayfarer-hud-meter]'); if (meter) { meter.setAttribute('aria-valuenow', String(value)); } }); }); } function renderRuntimeState(state) { if ( !state || typeof state !== 'object' ) { return; } renderHudState( state ); if ( state.navigation && state.navigation.position !== undefined ) { const position = String( state.navigation.position ); const authorPosition = document.getElementById( 'wayfarer-author-position' ); if (authorPosition) { authorPosition.textContent = position; } } const gameTraits = state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : {}; document .querySelectorAll( '[data-wayfarer-game-trait-value]' ) .forEach( function (cell) { const traitId = cell.dataset .wayfarerGameTraitValue; if ( Object.prototype .hasOwnProperty .call( gameTraits, traitId ) ) { cell.textContent = displayRuntimeValue( gameTraits[traitId] ); } } ); document .querySelectorAll( '[data-wayfarer-region-trait-value]' ) .forEach( function (cell) { const regionId = cell.dataset .wayfarerRegionId; const traitId = cell.dataset .wayfarerRegionTraitValue; const traits = state.regions && state.regions[regionId] && state.regions[regionId].traits && typeof state.regions[regionId].traits === 'object' ? state.regions[regionId].traits : null; if ( traits && Object.prototype .hasOwnProperty .call( traits, traitId ) ) { cell.textContent = displayRuntimeValue( traits[traitId] ); } } ); const debug = document.getElementById( 'wayfarer-runtime-debug' ); if (debug) { debug.textContent = JSON.stringify( state, null, 2 ); } } async function requestTick() { if ( requestInFlight || presentationActive ) { return null; } requestInFlight = true; renderControls(); try { const response = await fetch( config.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ travel_direction: requestedTravelDirection }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Tick endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Tick request failed.' ); } if (tickValue) { tickValue.textContent = String( data.tick ); } renderRuntimeState( data.state ); /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ if ( data.state && data.state.navigation && Object.prototype .hasOwnProperty .call( data.state.navigation, 'direction' ) ) { requestedTravelDirection = Number( data.state.navigation.direction ) < 0 ? -1 : 1; } renderPlayerContext( data.context ); renderTravelStatus( data.travel ); renderStoryletActions( data.actions ); setMessage( 'Tick ' + data.tick + ' saved.' ); if (data.pause === true) { /* * Suppress Action Mode during the brief interval before reload. * The server-rendered presentation will exist after reload. */ presentationActive = true; pauseRuntime( data.pause_reason || 'Travel interrupted.', true ); } return data; } catch (error) { pauseRuntime( error instanceof Error ? error.message : 'Tick request failed.' ); setMessage( error instanceof Error ? error.message : 'Tick request failed.', true ); return null; } finally { requestInFlight = false; renderControls(); if (reloadWhenIdle) { reloadWhenIdle = false; window.location.reload(); } } } async function runAutomaticTick() { if ( !running || presentationActive ) { return; } const result = await requestTick(); if ( !running || presentationActive || result === null ) { return; } timeoutId = window.setTimeout( runAutomaticTick, Number( config.intervalMs ) || 1500 ); } function startRuntime() { if ( running || requestInFlight || presentationActive ) { return; } /* * Travel always leaves inspection/context views and returns the card to * its normal Action view for the next pause. */ setActionView( 'actions' ); running = true; setMessage( 'Automatic advancement started.' ); renderControls(); emitModeEvent(); runAutomaticTick(); } /* * WAYFARER_PRESENTATION_TRANSITION_V63 * * Cover the current DOM before a server-rendered Presentation replaces it. * This prevents the previous Location/Storylet copy from being painted for * a frame during the navigation boundary. */ function beginPresentationTransition() { body.classList.add( 'wayfarer-presentation-transitioning' ); } function pauseRuntime( reason = 'Automatic advancement paused.', reloadForPresentation = false ) { const wasRunning = running; running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } setMessage( reason ); if (wasRunning) { setActionView( 'actions' ); } renderControls(); if (wasRunning) { emitModeEvent(); } if (reloadForPresentation) { /* WAYFARER_PRESENTATION_TRANSITION_V63 */ beginPresentationTransition(); if (requestInFlight) { reloadWhenIdle = true; } else { window.location.reload(); } } } /* * WAYFARER_STORYLET_ACTION_RUNTIME_V59 */ async function requestStorylet( storyletId, entryMode = 'action', parentStoryletId = 0 ) { if ( storyletRequestInFlight || requestInFlight || presentationRequestInFlight ) { return; } storyletId = Number(storyletId || 0); parentStoryletId = Number(parentStoryletId || 0); if (storyletId <= 0) { return; } storyletRequestInFlight = true; renderControls(); try { const response = await fetch( config.storyletEndpoint || 'api/storylet.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, storylet_id: storyletId, entry_mode: entryMode, parent_storylet_id: parentStoryletId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Storylet endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not enter Storylet.' ); } /* * Storylet presentation/Branch markup is server-rendered. Reload * only after the runtime transaction succeeds, so Effects cannot be * repeated by a failed or duplicate browser transition. */ /* WAYFARER_PRESENTATION_TRANSITION_V63 */ beginPresentationTransition(); window.location.reload(); } catch (error) { window.alert( error instanceof Error ? error.message : 'Could not enter Storylet.' ); } finally { storyletRequestInFlight = false; renderControls(); } } async function dismissPresentation() { if ( !presentationActive || !presentationScreen || presentationRequestInFlight ) { return; } presentationRequestInFlight = true; if (presentationContinueButton) { presentationContinueButton.disabled = true; } const presentationType = presentationScreen.dataset .wayfarerPresentationType || ''; const presentationId = Number( presentationScreen.dataset .wayfarerPresentationId || 0 ); try { const response = await fetch( config.presentationEndpoint || 'api/presentation.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, type: presentationType, id: presentationId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Presentation endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not continue.' ); } presentationActive = false; setActionView( 'actions' ); renderRuntimeState( data.state ); renderPlayerContext( data.context ); renderStoryletActions( data.actions ); renderControls(); emitModeEvent(); /* * Place keyboard focus on the first available Action so the * transition works cleanly for keyboard players too. */ const firstAction = actionScreen ? actionScreen.querySelector( 'button:not([disabled]), a[href]' ) : null; if ( firstAction && typeof firstAction.focus === 'function' ) { firstAction.focus(); } } catch (error) { if (presentationContinueButton) { presentationContinueButton.disabled = false; } window.alert( error instanceof Error ? error.message : 'Could not continue.' ); } finally { presentationRequestInFlight = false; /* WAYFARER_PRESENTATION_CONTROL_RESET_V62 * * dismissPresentation() previously refreshed Action controls while * presentationRequestInFlight was still true. Newly-rendered Manual * Storylet buttons were therefore left disabled after a Location or * Storylet presentation was dismissed. Re-render once the request * flag has actually been cleared. */ renderControls(); if ( presentationContinueButton && presentationActive ) { presentationContinueButton.disabled = false; } } } if (onceButton) { onceButton.addEventListener( 'click', async function () { if ( running || presentationActive ) { return; } setMessage(''); await requestTick(); } ); } if (autoButton) { autoButton.addEventListener( 'click', startRuntime ); } if (authorPauseButton) { authorPauseButton.addEventListener( 'click', function () { pauseRuntime( 'Automatic advancement paused.' ); } ); } /* WAYFARER_PLAYER_TURN_AROUND_UI_V72 */ if (turnAroundButton) { turnAroundButton.addEventListener( 'click', function () { requestedTravelDirection = requestedTravelDirection < 0 ? 1 : -1; startRuntime(); } ); } if (continueTravelButton) { continueTravelButton.addEventListener( 'click', startRuntime ); } if (playerPauseButton) { playerPauseButton.addEventListener( 'click', function () { /* * WAYFARER_SMOOTH_MANUAL_PAUSE_V46_2 * * v46.1 keeps Action context current in-place, so manual Pause * no longer needs a full page reload. */ pauseRuntime( 'Travel paused.' ); } ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Ensure V50 detail panels physically belong to the Action card. This is * harmless when the PHP view already placed them correctly, and repairs * any earlier V50 markup that landed just outside the host. */ if (actionScreen) { document .querySelectorAll( '.wayfarer-entity-detail-view' ) .forEach( function (panel) { if ( !actionScreen.contains( panel ) ) { actionScreen.appendChild( panel ); } } ); /* * Delegate navigation from the Action card itself. Any current or * future control using the generic data attributes automatically * participates in the same router. */ actionScreen.addEventListener( 'click', function (event) { const storyletAction = event.target.closest( '[data-wayfarer-storylet-action-id]' ); if ( storyletAction && actionScreen.contains( storyletAction ) ) { if ( running || presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( storyletAction.dataset .wayfarerStoryletActionId, 'action', 0 ); return; } const opener = event.target.closest( '[data-wayfarer-open-action-view]' ); if ( opener && actionScreen.contains( opener ) ) { if ( running || presentationActive ) { return; } event.preventDefault(); setActionView( opener.dataset .wayfarerOpenActionView || 'actions' ); return; } const closer = event.target.closest( '[data-wayfarer-close-action-view]' ); if ( !closer || !actionScreen.contains( closer ) ) { return; } event.preventDefault(); const returnView = closer.dataset .wayfarerReturnActionView || 'actions'; setActionView( returnView ); let focusTarget = null; if (returnView === 'actions') { focusTarget = actionList ? actionList.querySelector( 'button:not([disabled]), a[href]' ) : null; } else { const parentPanel = actionScreen.querySelector( '[data-wayfarer-action-view="' + returnView + '"]' ); focusTarget = parentPanel ? parentPanel.querySelector( '[data-wayfarer-open-action-view], button:not([disabled]), a[href]' ) : null; } if ( focusTarget && typeof focusTarget.focus === 'function' ) { focusTarget.focus(); } } ); } /* WAYFARER_STORYLET_BRANCH_SELECTION_V59 */ document.addEventListener( 'click', function (event) { const branch = event.target.closest( '[data-wayfarer-storylet-branch-id]' ); if (!branch) { return; } if ( !presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( branch.dataset .wayfarerStoryletBranchId, 'branch', branch.dataset .wayfarerStoryletParentId ); } ); if (presentationContinueButton) { presentationContinueButton.addEventListener( 'click', dismissPresentation ); } document.addEventListener( 'keydown', function (event) { if ( !presentationActive || presentationRequestInFlight || storyletRequestInFlight || !presentationContinueButton ) { return; } const target = event.target; if ( target && target.matches && target.matches( 'input, textarea, select, button, a[href], [contenteditable="true"]' ) ) { return; } if ( event.key !== 'Enter' && event.key !== ' ' ) { return; } event.preventDefault(); dismissPresentation(); } ); window.addEventListener( 'pagehide', function () { running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } } ); window.wayfarerRuntimeController = { start: startRuntime, pause: pauseRuntime, dismissPresentation: dismissPresentation, enterStorylet: requestStorylet, isRunning: function () { return running; }, mode: function () { if (presentationActive) { return 'presentation'; } return running ? 'running' : 'paused'; } }; setActionView( 'actions' ); renderControls(); emitModeEvent(); })(); /* |-------------------------------------------------------------------------- | WAYFARER PLAY SHELL v20 |-------------------------------------------------------------------------- | | Reorganizes existing server-rendered nodes into the viewport shell. | Existing nodes are MOVED, not cloned/replaced, so the tick-control | event listeners installed above remain intact. | */ (() => { 'use strict'; if ( document.getElementById( 'wayfarer-play-shell' ) ) { return; } function exactHeading( level, text ) { const headings = document.querySelectorAll( level ); for (const heading of headings) { if ( heading.textContent .replace(/\s+/g, ' ') .trim() === text ) { return heading; } } return null; } function moveRange( start, stopExclusive, destination ) { if ( !start || !destination ) { return; } let node = start; while ( node && node !== stopExclusive ) { const next = node.nextSibling; destination.appendChild( node ); node = next; } } const body = document.body; /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * PHP decides whether Author Mode is permitted. JavaScript only shapes the * interface around that server-side decision. */ const canUseAuthorMode = body.dataset.wayfarerAuthorMode === '1'; body.classList.add( 'wayfarer-play-page' ); const shell = document.createElement( 'div' ); shell.id = 'wayfarer-play-shell'; shell.className = 'wayfarer-play-shell'; const header = document.createElement( 'header' ); header.className = 'wayfarer-play-shell-header'; const title = document.createElement( 'div' ); title.className = 'wayfarer-play-title'; const actions = document.createElement( 'div' ); actions.className = 'wayfarer-shell-actions'; const toggleAuthor = document.createElement( 'button' ); toggleAuthor.type = 'button'; toggleAuthor.className = 'wayfarer-shell-button'; if (canUseAuthorMode) { actions.appendChild( toggleAuthor ); } header.appendChild( title ); if (canUseAuthorMode) { header.appendChild( actions ); } const main = document.createElement( 'main' ); main.className = 'wayfarer-play-main'; const playerPane = document.createElement( 'section' ); playerPane.className = 'wayfarer-player-pane'; const authorPane = document.createElement( 'aside' ); authorPane.className = 'wayfarer-author-pane'; const authorHeader = document.createElement( 'div' ); authorHeader.className = 'wayfarer-author-header'; const authorToolbar = document.createElement( 'div' ); authorToolbar.className = 'wayfarer-author-toolbar'; const tabBar = document.createElement( 'div' ); tabBar.className = 'wayfarer-author-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const authorContent = document.createElement( 'div' ); authorContent.className = 'wayfarer-author-content'; authorPane.appendChild( authorHeader ); authorPane.appendChild( authorToolbar ); authorPane.appendChild( tabBar ); authorPane.appendChild( authorContent ); main.appendChild( playerPane ); if (canUseAuthorMode) { main.appendChild( authorPane ); } else { /* * Keep the player workspace full-width when Author Mode is unavailable. * No Author pane was rendered by PHP and none is attached here. */ shell.classList.add( 'author-collapsed' ); } shell.appendChild( header ); shell.appendChild( main ); body.insertBefore( shell, body.firstChild ); // ------------------------------------------------------------ // Top title/player identity // ------------------------------------------------------------ const pageTitle = Array.from( body.querySelectorAll( ':scope > h1' ) )[0]; if (pageTitle) { const playerIdentity = pageTitle.nextElementSibling; title.appendChild( pageTitle ); if ( playerIdentity && playerIdentity.tagName === 'P' ) { title.appendChild( playerIdentity ); } } // ------------------------------------------------------------ // Player content // ------------------------------------------------------------ /* * WAYFARER_PLAYER_START_SCREEN_V36 * * Before runtime state exists, the start screen owns the full player * workspace and Author Mode is intentionally hidden. */ const startScreen = document.querySelector( '[data-wayfarer-start-screen]' ); if (startScreen) { shell.classList.add( 'is-starting' ); playerPane.appendChild( startScreen ); } const scene = document.getElementById( 'wayfarer-player-scene' ); const gameStateHeading = exactHeading( 'h2', 'Game State' ) || exactHeading( 'h2', 'Game Not Initialized' ); if (gameStateHeading) { const meta = document.createElement( 'div' ); meta.className = 'wayfarer-player-meta'; /* * Move from Game State through the node immediately before the * player scene. This includes initialization messaging/forms but * stops before the actual scene. */ moveRange( gameStateHeading, scene, meta ); playerPane.appendChild( meta ); } if (scene) { playerPane.appendChild( scene ); } /* * WAYFARER_ACTION_SCREEN_V39 * * Action Mode is player-facing and exists independently of Author Mode. */ const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); if (actionScreen) { playerPane.appendChild( actionScreen ); } /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); if (presentationScreen) { playerPane.appendChild( presentationScreen ); } const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); if (travelControls) { playerPane.appendChild( travelControls ); } /* WAYFARER_HUD_SURFACE_PLACEMENT_V95 */ document .querySelectorAll('[data-wayfarer-hud]') .forEach(function (persistentHud) { playerPane.appendChild( persistentHud ); }); // ------------------------------------------------------------ // Author header // ------------------------------------------------------------ const authorHeading = exactHeading( 'h2', 'Author Mode' ); if (authorHeading) { const builderLinkBlock = authorHeading.nextElementSibling; authorHeader.appendChild( authorHeading ); if ( builderLinkBlock && builderLinkBlock.tagName === 'P' ) { authorHeader.appendChild( builderLinkBlock ); } } // ------------------------------------------------------------ // Runtime controls // ------------------------------------------------------------ const tickPanel = document.getElementById( 'wayfarer-tick-test' ); if (tickPanel) { authorToolbar.appendChild( tickPanel ); } // ------------------------------------------------------------ // Author tabs // ------------------------------------------------------------ /* WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 */ const panelDefinitions = [ { key: 'state', label: 'State' }, { key: 'debug', label: 'Debug' } ]; function activateTab(key) { document .querySelectorAll( '.wayfarer-author-tab' ) .forEach( function (button) { const active = button.dataset .wayfarerAuthorTab === key; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); authorContent .querySelectorAll( '.wayfarer-author-tab-panel' ) .forEach( function (panel) { panel.classList.toggle( 'is-active', panel.dataset .wayfarerAuthorPanel === key ); } ); try { window.localStorage.setItem( 'wayfarerAuthorTab', key ); } catch (error) { // Storage is optional. } } for (const definition of panelDefinitions) { const panel = document.querySelector( '[data-wayfarer-author-panel="' + definition.key + '"]' ); if (!panel) { continue; } authorContent.appendChild( panel ); const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-author-tab'; button.dataset.wayfarerAuthorTab = definition.key; button.setAttribute( 'role', 'tab' ); button.textContent = definition.label; button.addEventListener( 'click', function () { activateTab( definition.key ); } ); tabBar.appendChild( button ); } let initialTab = 'state'; try { let savedTab = window.localStorage.getItem( 'wayfarerAuthorTab' ); if ( savedTab === 'region' ) { savedTab = 'state'; window.localStorage.setItem( 'wayfarerAuthorTab', 'state' ); } if ( savedTab && authorContent.querySelector( '[data-wayfarer-author-panel="' + savedTab + '"]' ) ) { initialTab = savedTab; } } catch (error) { // Storage is optional. } activateTab( initialTab ); // ------------------------------------------------------------ // Hide / Show Author Mode // WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 // ------------------------------------------------------------ if (canUseAuthorMode) { function renderAuthorToggle() { const collapsed = shell.classList.contains( 'author-collapsed' ); toggleAuthor.textContent = collapsed ? 'Show Author' : 'Hide Author'; toggleAuthor.setAttribute( 'aria-expanded', collapsed ? 'false' : 'true' ); } toggleAuthor.addEventListener( 'click', function () { shell.classList.toggle( 'author-collapsed' ); try { window.localStorage.setItem( 'wayfarerAuthorCollapsed', shell.classList.contains( 'author-collapsed' ) ? '1' : '0' ); } catch (error) { // Storage is optional. } renderAuthorToggle(); } ); try { if ( window.localStorage.getItem( 'wayfarerAuthorCollapsed' ) === '1' ) { shell.classList.add( 'author-collapsed' ); } } catch (error) { // Storage is optional. } renderAuthorToggle(); } else { /* * Player role is not merely a hidden Author interface. Keep the shell * permanently in player-only layout. */ shell.classList.add( 'author-collapsed' ); } // ------------------------------------------------------------ // Remove otherwise-empty top-level HRs left by the old document flow. // ------------------------------------------------------------ body .querySelectorAll( ':scope > hr' ) .forEach( function (rule) { rule.remove(); } ); })(); /* |-------------------------------------------------------------------------- | ENDING RESTART | WAYFARER_ENDING_RESTART_V63 |-------------------------------------------------------------------------- */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; const restartButton = document.querySelector( '[data-wayfarer-restart-game]' ); if ( !config || !restartButton ) { return; } let requestInFlight = false; restartButton.addEventListener( 'click', async function () { if (requestInFlight) { return; } if ( !window.confirm( 'Start this game over? Your current playthrough progress will be erased.' ) ) { return; } requestInFlight = true; restartButton.disabled = true; const originalText = restartButton.textContent; restartButton.textContent = 'Starting over…'; try { const response = await fetch( config.restartEndpoint || 'api/restart.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Restart endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not restart the game.' ); } document.body.classList.add( 'wayfarer-presentation-transitioning' ); window.location.href = 'index.php'; } catch (error) { requestInFlight = false; restartButton.disabled = false; restartButton.textContent = originalText; window.alert( error instanceof Error ? error.message : 'Could not restart the game.' ); } } ); })(); ================================================================================ FILE: _update_backups/v97-20260930-181941/play/index.php ================================================================================ prepare(" SELECT g.id AS game_id, g.name AS game_name, g.slug AS game_slug, g.instructions AS game_instructions, g.player_slots AS player_slots, g.config AS game_config, gu.id AS game_user_id, gu.role AS game_user_role, gu.state, u.username, u.display_name FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id INNER JOIN wf_users u ON u.id = gu.user_id WHERE gu.game_id = ? AND gu.user_id = ? LIMIT 1 "); $stmt->execute([ $gameId, $userId ]); $gameUser = $stmt->fetch(); if (!$gameUser) { die('Game user not found.'); } /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * Author permission belongs to the user/game relationship, not to the user * globally. wf_game_users.role is therefore the canonical source. */ $canUseAuthorMode = strtolower( trim( (string)( $gameUser['game_user_role'] ?? '' ) ) ) === 'author'; // ================================================================ // RUNTIME STATE // ================================================================ $state = null; if (!empty($gameUser['state'])) { $decodedState = json_decode( $gameUser['state'], true ); if (is_array($decodedState)) { $state = $decodedState; } } /* * WAYFARER_ENTITY_RUNTIME_STATE_PAGE_RECOVERY_V49 * * Existing development playthroughs should gain Entity state immediately on * page load. This is additive and does not overwrite current runtime values. */ if ($state !== null) { $runtimeRecoveryChanged = wayfarerRuntimeEnsureEntityState( $pdo, (int)$gameId, $state ); /* WAYFARER_PLAYER_TRAITS_PAGE_RECOVERY_V52 */ if ( wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$gameId, $state ) ) { $runtimeRecoveryChanged = true; } if ($runtimeRecoveryChanged) { $encodedRecoveredState = wayfarerRuntimeEncodeState( $state ); $entityRecoveryStmt = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $entityRecoveryStmt->execute([ $encodedRecoveredState, (int)$gameUser['game_user_id'], ]); /* * Keep Author Debug and any later page component on the same snapshot. */ $gameUser['state'] = $encodedRecoveredState; } } // ================================================================ // INITIALIZE GAME // ================================================================ $initializationError = ''; if ( $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'initialize' && $state === null ) { try { wayfarerRuntimeInitializeGame( $pdo, (int)$gameUser['game_user_id'], [ 'player_names' => $_POST['player_names'] ?? [], 'choices' => $_POST['setup_choices'] ?? [], ] ); /* * Reload from persisted state so every play-page component sees the * exact same initialized snapshot. */ header('Location: index.php'); exit; } catch (Throwable $exception) { $initializationError = $exception->getMessage(); } } // ================================================================ // AUTHOR MODE: GAME TRAIT DEFINITIONS // ================================================================ $gameTraitDefinitions = []; $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } foreach ($gameConfig['traits'] ?? [] as $trait) { if (empty($trait['id'])) { continue; } $gameTraitDefinitions[$trait['id']] = $trait; } ?> <?= htmlspecialchars($gameUser['game_name']) ?>

Player:



================================================================================ FILE: _update_backups/v98-20260930-182445/play/assets/play.js ================================================================================ /* |-------------------------------------------------------------------------- | PLAYER RUNTIME CONTROLLER | WAYFARER_ACTION_SCREEN_V39 | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | Runtime modes currently exposed to the player: | | presentation -> Action -> running navigation -> presentation/Action | | Presentation is persisted by PHP. Automatic tick advancement cannot continue | while a presentation is active. | */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; if (!config) { return; } const body = document.body; const tickValue = document.getElementById( 'wayfarer-tick-value' ); const statusValue = document.getElementById( 'wayfarer-tick-status' ); const message = document.getElementById( 'wayfarer-tick-message' ); const onceButton = document.getElementById( 'wayfarer-tick-once' ); const autoButton = document.getElementById( 'wayfarer-tick-auto' ); const authorPauseButton = document.getElementById( 'wayfarer-tick-pause' ); const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); /* WAYFARER_HUD_SURFACES_LIVE_V95 */ const playerHuds = Array.from( document.querySelectorAll( '[data-wayfarer-hud]' ) ); /* WAYFARER_TRAVEL_TIME_HUD_V61 */ const travelTime = document.querySelector( '[data-wayfarer-travel-time]' ); const travelTimeSeparator = document.querySelector( '[data-wayfarer-travel-time-separator]' ); const travelNext = document.querySelector( '[data-wayfarer-travel-next]' ); const continueTravelButton = document.querySelector( '[data-wayfarer-continue-travel]' ); const playerPauseButton = document.querySelector( '[data-wayfarer-pause-travel]' ); /* WAYFARER_PLAYER_TURN_AROUND_UI_V72 */ const turnAroundButton = document.querySelector( '[data-wayfarer-turn-around]' ); /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Action Mode owns one physical card. Inventory, Status, and future * utility/context views temporarily replace the normal action heading/list * inside that card without becoming persisted runtime modes. */ const actionHeading = actionScreen ? actionScreen.querySelector( '.wayfarer-action-heading' ) : null; const actionList = actionScreen ? actionScreen.querySelector( '[data-wayfarer-action-list]' ) : null; const actionSubviewPanels = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ) : []; const actionViewOpeners = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-open-action-view]' ) ) : []; const actionViewClosers = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-close-action-view]' ) ) : []; /* * Persisted narrative presentation. */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); const presentationContinueButton = presentationScreen ? presentationScreen.querySelector( '[data-wayfarer-presentation-continue]' ) : null; let running = false; let requestInFlight = false; let presentationRequestInFlight = false; let storyletRequestInFlight = false; let timeoutId = null; let reloadWhenIdle = false; /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ let requestedTravelDirection = Number( config.initialDirection ) < 0 ? -1 : 1; let presentationActive = Boolean( presentationScreen && !presentationScreen.hidden ); function setMessage( text, isError = false ) { if (!message) { return; } message.textContent = text; message.style.color = isError ? 'red' : ''; } function emitModeEvent() { let mode = 'paused'; if (presentationActive) { mode = 'presentation'; } else if (running) { mode = 'running'; } window.dispatchEvent( new CustomEvent( 'wayfarer:runtime-mode', { detail: { mode: mode } } ) ); /* * Keep the v39 event names available for existing development hooks. */ window.dispatchEvent( new CustomEvent( running ? 'wayfarer:runtime-running' : 'wayfarer:runtime-paused', { detail: { mode: mode } } ) ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Generic in-card view switcher. Future Map / Shop / other views can use * the same data-wayfarer-open-action-view + data-wayfarer-action-view * contract without adding another page-level layout system. */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Resolve available views at the moment navigation occurs instead of * relying on the V48 page-load snapshot. This supports nested Entity * detail screens and future context views added to the same Action card. */ function setActionView(viewName = 'actions') { if (!actionScreen) { return; } const requested = String( viewName || 'actions' ); const showActions = requested === 'actions'; const panels = Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ); let matched = showActions; panels.forEach( function (panel) { const active = panel.dataset .wayfarerActionView === requested; panel.hidden = !active; if (active) { matched = true; } } ); /* * Fail safely if a malformed/removed view name is requested. */ if (!matched) { panels.forEach( function (panel) { panel.hidden = true; } ); if (actionHeading) { actionHeading.hidden = false; } if (actionList) { actionList.hidden = false; } return; } if (actionHeading) { actionHeading.hidden = !showActions; } if (actionList) { actionList.hidden = !showActions; } } function renderControls() { if (statusValue) { statusValue.textContent = presentationActive ? 'Presentation' : ( running ? 'Running' : 'Paused' ); } if (onceButton) { onceButton.disabled = running || requestInFlight || presentationActive; } if (autoButton) { autoButton.disabled = running || requestInFlight || presentationActive; } if (authorPauseButton) { authorPauseButton.disabled = !running; } if (continueTravelButton) { continueTravelButton.disabled = running || requestInFlight || presentationActive; } document .querySelectorAll( '[data-wayfarer-storylet-action-id], ' + '[data-wayfarer-storylet-branch-id]' ) .forEach( function (button) { button.disabled = requestInFlight || presentationRequestInFlight || storyletRequestInFlight || ( button.hasAttribute( 'data-wayfarer-storylet-action-id' ) && (running || presentationActive) ); } ); if (playerPauseButton) { playerPauseButton.disabled = !running; } /* WAYFARER_PLAYER_TURN_AROUND_UI_V72 */ if (turnAroundButton) { turnAroundButton.disabled = running || requestInFlight || presentationActive; } if (actionScreen) { actionScreen.hidden = running || presentationActive; } if (travelControls) { travelControls.hidden = !running || presentationActive; } /* WAYFARER_HUD_SURFACE_VISIBILITY_V95 */ playerHuds.forEach(function (hud) { const surface = String( hud.dataset.wayfarerHudSurface || 'travel' ); if (surface === 'action') { hud.hidden = presentationActive || running; return; } hud.hidden = presentationActive || !running; }); if (presentationScreen) { presentationScreen.hidden = !presentationActive; presentationScreen.setAttribute( 'aria-hidden', presentationActive ? 'false' : 'true' ); } body.classList.toggle( 'wayfarer-runtime-running', running ); body.classList.toggle( 'wayfarer-runtime-paused', !running && !presentationActive ); body.classList.toggle( 'wayfarer-runtime-presentation', presentationActive ); } /* * WAYFARER_LIVE_ACTION_CONTEXT_V46_1 * * Action Mode must reflect the current runtime position, not the page's * original server-rendered context. */ function renderPlayerContext(context) { if ( !context || typeof context !== 'object' ) { return; } const heading = document.querySelector( '[data-wayfarer-action-context-name]' ); const parent = document.querySelector( '[data-wayfarer-action-parent-context]' ); if (heading) { const name = String( context.context_name || 'Actions' ).trim(); heading.textContent = name || 'Actions'; } if (parent) { const parentText = String( context.parent_context || '' ).trim(); parent.textContent = parentText; parent.hidden = parentText === ''; } } /* WAYFARER_TRAVEL_TIME_HUD_RENDER_V61 */ function renderTravelStatus(travel) { if (!travel || typeof travel !== 'object') { return; } const timeLabel = String(travel.time_label || '').trim(); if (travelTime) { travelTime.textContent = timeLabel; travelTime.hidden = timeLabel === ''; } if (travelTimeSeparator) { travelTimeSeparator.hidden = timeLabel === ''; } const nextLabel = String(travel.next_label || '').trim(); if (travelNext) { travelNext.textContent = nextLabel === '' ? '' : 'Next: ' + nextLabel; travelNext.hidden = nextLabel === ''; } } function displayRuntimeValue(value) { if (typeof value === 'boolean') { return value ? 'True' : 'False'; } if ( value === null || value === undefined ) { return 'null'; } if (typeof value === 'object') { return JSON.stringify( value ); } return String( value ); } /* * WAYFARER_STORYLET_ACTIONS_LIVE_V59 * * Tick/presentation APIs return the currently eligible root Manual * Storylets so Action Mode does not depend on the page-load snapshot. */ function renderStoryletActions(actions) { if (!actionScreen) { return; } const host = actionScreen.querySelector( '[data-wayfarer-storylet-actions]' ); if (!host) { return; } const normalized = Array.isArray(actions) ? actions : []; host.innerHTML = ''; normalized.forEach( function (action) { const id = Number( action && action.id ? action.id : 0 ); if (id <= 0) { return; } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-action-button'; button.dataset.wayfarerStoryletActionId = String(id); button.textContent = String( action.label || action.name || 'Storylet' ); host.appendChild( button ); } ); const empty = actionScreen.querySelector( '[data-wayfarer-action-empty]' ); if (empty) { const canTravel = actionScreen.dataset.wayfarerCanTravel === '1'; empty.hidden = canTravel || host.children.length > 0; } renderControls(); } /* * WAYFARER_GAME_TRAVEL_HUD_LIVE_V94 * * HUD components carry a direct runtime Trait address. Tick responses * already include canonical state, so Number and Progress Bar values can * update in place without a separate HUD endpoint. */ function resolveHudTraits(state, scope) { if (!state || typeof state !== 'object') { return null; } if (scope === 'game') { return state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : null; } const match = String(scope || '').match(/^(region|location|entity):([1-9][0-9]*)$/); if (!match) { return null; } const bucket = match[1] === 'region' ? 'regions' : (match[1] === 'location' ? 'locations' : 'entities'); const key = match[2]; return state[bucket] && state[bucket][key] && state[bucket][key].traits && typeof state[bucket][key].traits === 'object' ? state[bucket][key].traits : null; } function formatHudNumber(value) { const number = Number(value); if (!Number.isFinite(number)) { return String(value ?? ''); } return Number.isInteger(number) ? String(number) : String(Math.round(number * 100) / 100); } function renderHudState(state) { if (!playerHuds.length || !state || typeof state !== 'object') { return; } playerHuds.forEach(function (playerHud) { playerHud .querySelectorAll('[data-wayfarer-hud-component]') .forEach(function (component) { const type = String(component.dataset.wayfarerHudType || 'number'); const scope = String(component.dataset.wayfarerHudScope || ''); const sourceId = String(component.dataset.wayfarerHudSourceId || ''); const traits = resolveHudTraits(state, scope); if ( !traits || !Object.prototype.hasOwnProperty.call(traits, sourceId) ) { return; } const value = Number(traits[sourceId]); if (!Number.isFinite(value)) { return; } const valueNode = component.querySelector('[data-wayfarer-hud-value]'); if (valueNode) { valueNode.textContent = formatHudNumber(value); } if (type !== 'progress_bar') { return; } const min = Number(component.dataset.wayfarerHudMin); const max = Number(component.dataset.wayfarerHudMax); if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) { return; } const percent = Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)); const fill = component.querySelector('[data-wayfarer-hud-fill]'); if (fill) { fill.style.width = percent + '%'; } const meter = component.querySelector('[data-wayfarer-hud-meter]'); if (meter) { meter.setAttribute('aria-valuenow', String(value)); } }); }); } function renderRuntimeState(state) { if ( !state || typeof state !== 'object' ) { return; } renderHudState( state ); if ( state.navigation && state.navigation.position !== undefined ) { const position = String( state.navigation.position ); const authorPosition = document.getElementById( 'wayfarer-author-position' ); if (authorPosition) { authorPosition.textContent = position; } } const gameTraits = state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : {}; document .querySelectorAll( '[data-wayfarer-game-trait-value]' ) .forEach( function (cell) { const traitId = cell.dataset .wayfarerGameTraitValue; if ( Object.prototype .hasOwnProperty .call( gameTraits, traitId ) ) { cell.textContent = displayRuntimeValue( gameTraits[traitId] ); } } ); document .querySelectorAll( '[data-wayfarer-region-trait-value]' ) .forEach( function (cell) { const regionId = cell.dataset .wayfarerRegionId; const traitId = cell.dataset .wayfarerRegionTraitValue; const traits = state.regions && state.regions[regionId] && state.regions[regionId].traits && typeof state.regions[regionId].traits === 'object' ? state.regions[regionId].traits : null; if ( traits && Object.prototype .hasOwnProperty .call( traits, traitId ) ) { cell.textContent = displayRuntimeValue( traits[traitId] ); } } ); const debug = document.getElementById( 'wayfarer-runtime-debug' ); if (debug) { debug.textContent = JSON.stringify( state, null, 2 ); } } async function requestTick() { if ( requestInFlight || presentationActive ) { return null; } requestInFlight = true; renderControls(); try { const response = await fetch( config.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ travel_direction: requestedTravelDirection }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Tick endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Tick request failed.' ); } if (tickValue) { tickValue.textContent = String( data.tick ); } renderRuntimeState( data.state ); /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ if ( data.state && data.state.navigation && Object.prototype .hasOwnProperty .call( data.state.navigation, 'direction' ) ) { requestedTravelDirection = Number( data.state.navigation.direction ) < 0 ? -1 : 1; } renderPlayerContext( data.context ); renderTravelStatus( data.travel ); renderStoryletActions( data.actions ); setMessage( 'Tick ' + data.tick + ' saved.' ); if (data.pause === true) { /* * Suppress Action Mode during the brief interval before reload. * The server-rendered presentation will exist after reload. */ presentationActive = true; pauseRuntime( data.pause_reason || 'Travel interrupted.', true ); } return data; } catch (error) { pauseRuntime( error instanceof Error ? error.message : 'Tick request failed.' ); setMessage( error instanceof Error ? error.message : 'Tick request failed.', true ); return null; } finally { requestInFlight = false; renderControls(); if (reloadWhenIdle) { reloadWhenIdle = false; window.location.reload(); } } } async function runAutomaticTick() { if ( !running || presentationActive ) { return; } const result = await requestTick(); if ( !running || presentationActive || result === null ) { return; } timeoutId = window.setTimeout( runAutomaticTick, Number( config.intervalMs ) || 1500 ); } function startRuntime() { if ( running || requestInFlight || presentationActive ) { return; } /* * Travel always leaves inspection/context views and returns the card to * its normal Action view for the next pause. */ setActionView( 'actions' ); running = true; setMessage( 'Automatic advancement started.' ); renderControls(); emitModeEvent(); runAutomaticTick(); } /* * WAYFARER_PRESENTATION_TRANSITION_V63 * * Cover the current DOM before a server-rendered Presentation replaces it. * This prevents the previous Location/Storylet copy from being painted for * a frame during the navigation boundary. */ function beginPresentationTransition() { body.classList.add( 'wayfarer-presentation-transitioning' ); } function pauseRuntime( reason = 'Automatic advancement paused.', reloadForPresentation = false ) { const wasRunning = running; running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } setMessage( reason ); if (wasRunning) { setActionView( 'actions' ); } renderControls(); if (wasRunning) { emitModeEvent(); } if (reloadForPresentation) { /* WAYFARER_PRESENTATION_TRANSITION_V63 */ beginPresentationTransition(); if (requestInFlight) { reloadWhenIdle = true; } else { window.location.reload(); } } } /* * WAYFARER_STORYLET_ACTION_RUNTIME_V59 */ async function requestStorylet( storyletId, entryMode = 'action', parentStoryletId = 0 ) { if ( storyletRequestInFlight || requestInFlight || presentationRequestInFlight ) { return; } storyletId = Number(storyletId || 0); parentStoryletId = Number(parentStoryletId || 0); if (storyletId <= 0) { return; } storyletRequestInFlight = true; renderControls(); try { const response = await fetch( config.storyletEndpoint || 'api/storylet.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, storylet_id: storyletId, entry_mode: entryMode, parent_storylet_id: parentStoryletId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Storylet endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not enter Storylet.' ); } /* * Storylet presentation/Branch markup is server-rendered. Reload * only after the runtime transaction succeeds, so Effects cannot be * repeated by a failed or duplicate browser transition. */ /* WAYFARER_PRESENTATION_TRANSITION_V63 */ beginPresentationTransition(); window.location.reload(); } catch (error) { window.alert( error instanceof Error ? error.message : 'Could not enter Storylet.' ); } finally { storyletRequestInFlight = false; renderControls(); } } async function dismissPresentation() { if ( !presentationActive || !presentationScreen || presentationRequestInFlight ) { return; } presentationRequestInFlight = true; if (presentationContinueButton) { presentationContinueButton.disabled = true; } const presentationType = presentationScreen.dataset .wayfarerPresentationType || ''; const presentationId = Number( presentationScreen.dataset .wayfarerPresentationId || 0 ); try { const response = await fetch( config.presentationEndpoint || 'api/presentation.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, type: presentationType, id: presentationId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Presentation endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not continue.' ); } presentationActive = false; setActionView( 'actions' ); renderRuntimeState( data.state ); renderPlayerContext( data.context ); renderStoryletActions( data.actions ); renderControls(); emitModeEvent(); /* * Place keyboard focus on the first available Action so the * transition works cleanly for keyboard players too. */ const firstAction = actionScreen ? actionScreen.querySelector( 'button:not([disabled]), a[href]' ) : null; if ( firstAction && typeof firstAction.focus === 'function' ) { firstAction.focus(); } } catch (error) { if (presentationContinueButton) { presentationContinueButton.disabled = false; } window.alert( error instanceof Error ? error.message : 'Could not continue.' ); } finally { presentationRequestInFlight = false; /* WAYFARER_PRESENTATION_CONTROL_RESET_V62 * * dismissPresentation() previously refreshed Action controls while * presentationRequestInFlight was still true. Newly-rendered Manual * Storylet buttons were therefore left disabled after a Location or * Storylet presentation was dismissed. Re-render once the request * flag has actually been cleared. */ renderControls(); if ( presentationContinueButton && presentationActive ) { presentationContinueButton.disabled = false; } } } if (onceButton) { onceButton.addEventListener( 'click', async function () { if ( running || presentationActive ) { return; } setMessage(''); await requestTick(); } ); } if (autoButton) { autoButton.addEventListener( 'click', startRuntime ); } if (authorPauseButton) { authorPauseButton.addEventListener( 'click', function () { pauseRuntime( 'Automatic advancement paused.' ); } ); } /* WAYFARER_PLAYER_TURN_AROUND_UI_V72 */ if (turnAroundButton) { turnAroundButton.addEventListener( 'click', function () { requestedTravelDirection = requestedTravelDirection < 0 ? 1 : -1; startRuntime(); } ); } if (continueTravelButton) { continueTravelButton.addEventListener( 'click', startRuntime ); } if (playerPauseButton) { playerPauseButton.addEventListener( 'click', function () { /* * WAYFARER_SMOOTH_MANUAL_PAUSE_V46_2 * * v46.1 keeps Action context current in-place, so manual Pause * no longer needs a full page reload. */ pauseRuntime( 'Travel paused.' ); } ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Ensure V50 detail panels physically belong to the Action card. This is * harmless when the PHP view already placed them correctly, and repairs * any earlier V50 markup that landed just outside the host. */ if (actionScreen) { document .querySelectorAll( '.wayfarer-entity-detail-view' ) .forEach( function (panel) { if ( !actionScreen.contains( panel ) ) { actionScreen.appendChild( panel ); } } ); /* * Delegate navigation from the Action card itself. Any current or * future control using the generic data attributes automatically * participates in the same router. */ actionScreen.addEventListener( 'click', function (event) { const storyletAction = event.target.closest( '[data-wayfarer-storylet-action-id]' ); if ( storyletAction && actionScreen.contains( storyletAction ) ) { if ( running || presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( storyletAction.dataset .wayfarerStoryletActionId, 'action', 0 ); return; } const opener = event.target.closest( '[data-wayfarer-open-action-view]' ); if ( opener && actionScreen.contains( opener ) ) { if ( running || presentationActive ) { return; } event.preventDefault(); setActionView( opener.dataset .wayfarerOpenActionView || 'actions' ); return; } const closer = event.target.closest( '[data-wayfarer-close-action-view]' ); if ( !closer || !actionScreen.contains( closer ) ) { return; } event.preventDefault(); const returnView = closer.dataset .wayfarerReturnActionView || 'actions'; setActionView( returnView ); let focusTarget = null; if (returnView === 'actions') { focusTarget = actionList ? actionList.querySelector( 'button:not([disabled]), a[href]' ) : null; } else { const parentPanel = actionScreen.querySelector( '[data-wayfarer-action-view="' + returnView + '"]' ); focusTarget = parentPanel ? parentPanel.querySelector( '[data-wayfarer-open-action-view], button:not([disabled]), a[href]' ) : null; } if ( focusTarget && typeof focusTarget.focus === 'function' ) { focusTarget.focus(); } } ); } /* WAYFARER_STORYLET_BRANCH_SELECTION_V59 */ document.addEventListener( 'click', function (event) { const branch = event.target.closest( '[data-wayfarer-storylet-branch-id]' ); if (!branch) { return; } if ( !presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( branch.dataset .wayfarerStoryletBranchId, 'branch', branch.dataset .wayfarerStoryletParentId ); } ); if (presentationContinueButton) { presentationContinueButton.addEventListener( 'click', dismissPresentation ); } document.addEventListener( 'keydown', function (event) { if ( !presentationActive || presentationRequestInFlight || storyletRequestInFlight || !presentationContinueButton ) { return; } const target = event.target; if ( target && target.matches && target.matches( 'input, textarea, select, button, a[href], [contenteditable="true"]' ) ) { return; } if ( event.key !== 'Enter' && event.key !== ' ' ) { return; } event.preventDefault(); dismissPresentation(); } ); window.addEventListener( 'pagehide', function () { running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } } ); window.wayfarerRuntimeController = { start: startRuntime, pause: pauseRuntime, dismissPresentation: dismissPresentation, enterStorylet: requestStorylet, isRunning: function () { return running; }, mode: function () { if (presentationActive) { return 'presentation'; } return running ? 'running' : 'paused'; } }; setActionView( 'actions' ); renderControls(); emitModeEvent(); })(); /* |-------------------------------------------------------------------------- | WAYFARER PLAY SHELL v20 |-------------------------------------------------------------------------- | | Reorganizes existing server-rendered nodes into the viewport shell. | Existing nodes are MOVED, not cloned/replaced, so the tick-control | event listeners installed above remain intact. | */ (() => { 'use strict'; if ( document.getElementById( 'wayfarer-play-shell' ) ) { return; } function exactHeading( level, text ) { const headings = document.querySelectorAll( level ); for (const heading of headings) { if ( heading.textContent .replace(/\s+/g, ' ') .trim() === text ) { return heading; } } return null; } function moveRange( start, stopExclusive, destination ) { if ( !start || !destination ) { return; } let node = start; while ( node && node !== stopExclusive ) { const next = node.nextSibling; destination.appendChild( node ); node = next; } } const body = document.body; /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * PHP decides whether Author Mode is permitted. JavaScript only shapes the * interface around that server-side decision. */ const canUseAuthorMode = body.dataset.wayfarerAuthorMode === '1'; body.classList.add( 'wayfarer-play-page' ); const shell = document.createElement( 'div' ); shell.id = 'wayfarer-play-shell'; shell.className = 'wayfarer-play-shell'; const header = document.createElement( 'header' ); header.className = 'wayfarer-play-shell-header'; const title = document.createElement( 'div' ); title.className = 'wayfarer-play-title'; const actions = document.createElement( 'div' ); actions.className = 'wayfarer-shell-actions'; const toggleAuthor = document.createElement( 'button' ); toggleAuthor.type = 'button'; toggleAuthor.className = 'wayfarer-shell-button'; if (canUseAuthorMode) { actions.appendChild( toggleAuthor ); } header.appendChild( title ); if (canUseAuthorMode) { header.appendChild( actions ); } const main = document.createElement( 'main' ); main.className = 'wayfarer-play-main'; const playerPane = document.createElement( 'section' ); playerPane.className = 'wayfarer-player-pane'; const authorPane = document.createElement( 'aside' ); authorPane.className = 'wayfarer-author-pane'; const authorHeader = document.createElement( 'div' ); authorHeader.className = 'wayfarer-author-header'; const authorToolbar = document.createElement( 'div' ); authorToolbar.className = 'wayfarer-author-toolbar'; const tabBar = document.createElement( 'div' ); tabBar.className = 'wayfarer-author-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const authorContent = document.createElement( 'div' ); authorContent.className = 'wayfarer-author-content'; authorPane.appendChild( authorHeader ); authorPane.appendChild( authorToolbar ); authorPane.appendChild( tabBar ); authorPane.appendChild( authorContent ); main.appendChild( playerPane ); if (canUseAuthorMode) { main.appendChild( authorPane ); } else { /* * Keep the player workspace full-width when Author Mode is unavailable. * No Author pane was rendered by PHP and none is attached here. */ shell.classList.add( 'author-collapsed' ); } shell.appendChild( header ); shell.appendChild( main ); body.insertBefore( shell, body.firstChild ); // ------------------------------------------------------------ // Top title/player identity // ------------------------------------------------------------ const pageTitle = Array.from( body.querySelectorAll( ':scope > h1' ) )[0]; if (pageTitle) { const playerIdentity = pageTitle.nextElementSibling; title.appendChild( pageTitle ); if ( playerIdentity && playerIdentity.tagName === 'P' ) { title.appendChild( playerIdentity ); } } // ------------------------------------------------------------ // Player content // ------------------------------------------------------------ /* * WAYFARER_PLAYER_START_SCREEN_V36 * * Before runtime state exists, the start screen owns the full player * workspace and Author Mode is intentionally hidden. */ const startScreen = document.querySelector( '[data-wayfarer-start-screen]' ); if (startScreen) { shell.classList.add( 'is-starting' ); playerPane.appendChild( startScreen ); } const scene = document.getElementById( 'wayfarer-player-scene' ); const gameStateHeading = exactHeading( 'h2', 'Game State' ) || exactHeading( 'h2', 'Game Not Initialized' ); if (gameStateHeading) { const meta = document.createElement( 'div' ); meta.className = 'wayfarer-player-meta'; /* * Move from Game State through the node immediately before the * player scene. This includes initialization messaging/forms but * stops before the actual scene. */ moveRange( gameStateHeading, scene, meta ); playerPane.appendChild( meta ); } if (scene) { playerPane.appendChild( scene ); } /* * WAYFARER_ACTION_SCREEN_V39 * * Action Mode is player-facing and exists independently of Author Mode. */ const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); if (actionScreen) { playerPane.appendChild( actionScreen ); } /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); if (presentationScreen) { playerPane.appendChild( presentationScreen ); } const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); if (travelControls) { playerPane.appendChild( travelControls ); } /* * WAYFARER_HUD_SURFACE_PLACEMENT_V97 * * Travel HUD belongs immediately below the Travel status/controls card, * in the open Travel Stage area. Action HUD belongs immediately below the * Action card. Do not append both surfaces indiscriminately to the bottom * of the player pane. */ document .querySelectorAll('[data-wayfarer-hud]') .forEach(function (persistentHud) { const surface = String( persistentHud.dataset .wayfarerHudSurface || 'travel' ); if ( surface === 'travel' && travelControls && travelControls.parentNode ) { travelControls.insertAdjacentElement( 'afterend', persistentHud ); return; } if ( surface === 'action' && actionScreen && actionScreen.parentNode ) { actionScreen.insertAdjacentElement( 'afterend', persistentHud ); return; } /* * Fallback only for malformed/unknown surfaces. */ playerPane.appendChild( persistentHud ); }); // ------------------------------------------------------------ // Author header // ------------------------------------------------------------ const authorHeading = exactHeading( 'h2', 'Author Mode' ); if (authorHeading) { const builderLinkBlock = authorHeading.nextElementSibling; authorHeader.appendChild( authorHeading ); if ( builderLinkBlock && builderLinkBlock.tagName === 'P' ) { authorHeader.appendChild( builderLinkBlock ); } } // ------------------------------------------------------------ // Runtime controls // ------------------------------------------------------------ const tickPanel = document.getElementById( 'wayfarer-tick-test' ); if (tickPanel) { authorToolbar.appendChild( tickPanel ); } // ------------------------------------------------------------ // Author tabs // ------------------------------------------------------------ /* WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 */ const panelDefinitions = [ { key: 'state', label: 'State' }, { key: 'debug', label: 'Debug' } ]; function activateTab(key) { document .querySelectorAll( '.wayfarer-author-tab' ) .forEach( function (button) { const active = button.dataset .wayfarerAuthorTab === key; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); authorContent .querySelectorAll( '.wayfarer-author-tab-panel' ) .forEach( function (panel) { panel.classList.toggle( 'is-active', panel.dataset .wayfarerAuthorPanel === key ); } ); try { window.localStorage.setItem( 'wayfarerAuthorTab', key ); } catch (error) { // Storage is optional. } } for (const definition of panelDefinitions) { const panel = document.querySelector( '[data-wayfarer-author-panel="' + definition.key + '"]' ); if (!panel) { continue; } authorContent.appendChild( panel ); const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-author-tab'; button.dataset.wayfarerAuthorTab = definition.key; button.setAttribute( 'role', 'tab' ); button.textContent = definition.label; button.addEventListener( 'click', function () { activateTab( definition.key ); } ); tabBar.appendChild( button ); } let initialTab = 'state'; try { let savedTab = window.localStorage.getItem( 'wayfarerAuthorTab' ); if ( savedTab === 'region' ) { savedTab = 'state'; window.localStorage.setItem( 'wayfarerAuthorTab', 'state' ); } if ( savedTab && authorContent.querySelector( '[data-wayfarer-author-panel="' + savedTab + '"]' ) ) { initialTab = savedTab; } } catch (error) { // Storage is optional. } activateTab( initialTab ); // ------------------------------------------------------------ // Hide / Show Author Mode // WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 // ------------------------------------------------------------ if (canUseAuthorMode) { function renderAuthorToggle() { const collapsed = shell.classList.contains( 'author-collapsed' ); toggleAuthor.textContent = collapsed ? 'Show Author' : 'Hide Author'; toggleAuthor.setAttribute( 'aria-expanded', collapsed ? 'false' : 'true' ); } toggleAuthor.addEventListener( 'click', function () { shell.classList.toggle( 'author-collapsed' ); try { window.localStorage.setItem( 'wayfarerAuthorCollapsed', shell.classList.contains( 'author-collapsed' ) ? '1' : '0' ); } catch (error) { // Storage is optional. } renderAuthorToggle(); } ); try { if ( window.localStorage.getItem( 'wayfarerAuthorCollapsed' ) === '1' ) { shell.classList.add( 'author-collapsed' ); } } catch (error) { // Storage is optional. } renderAuthorToggle(); } else { /* * Player role is not merely a hidden Author interface. Keep the shell * permanently in player-only layout. */ shell.classList.add( 'author-collapsed' ); } // ------------------------------------------------------------ // Remove otherwise-empty top-level HRs left by the old document flow. // ------------------------------------------------------------ body .querySelectorAll( ':scope > hr' ) .forEach( function (rule) { rule.remove(); } ); })(); /* |-------------------------------------------------------------------------- | ENDING RESTART | WAYFARER_ENDING_RESTART_V63 |-------------------------------------------------------------------------- */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; const restartButton = document.querySelector( '[data-wayfarer-restart-game]' ); if ( !config || !restartButton ) { return; } let requestInFlight = false; restartButton.addEventListener( 'click', async function () { if (requestInFlight) { return; } if ( !window.confirm( 'Start this game over? Your current playthrough progress will be erased.' ) ) { return; } requestInFlight = true; restartButton.disabled = true; const originalText = restartButton.textContent; restartButton.textContent = 'Starting over…'; try { const response = await fetch( config.restartEndpoint || 'api/restart.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Restart endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not restart the game.' ); } document.body.classList.add( 'wayfarer-presentation-transitioning' ); window.location.href = 'index.php'; } catch (error) { requestInFlight = false; restartButton.disabled = false; restartButton.textContent = originalText; window.alert( error instanceof Error ? error.message : 'Could not restart the game.' ); } } ); })(); ================================================================================ FILE: _update_backups/v98-20260930-182445/play/index.php ================================================================================ prepare(" SELECT g.id AS game_id, g.name AS game_name, g.slug AS game_slug, g.instructions AS game_instructions, g.player_slots AS player_slots, g.config AS game_config, gu.id AS game_user_id, gu.role AS game_user_role, gu.state, u.username, u.display_name FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id INNER JOIN wf_users u ON u.id = gu.user_id WHERE gu.game_id = ? AND gu.user_id = ? LIMIT 1 "); $stmt->execute([ $gameId, $userId ]); $gameUser = $stmt->fetch(); if (!$gameUser) { die('Game user not found.'); } /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * Author permission belongs to the user/game relationship, not to the user * globally. wf_game_users.role is therefore the canonical source. */ $canUseAuthorMode = strtolower( trim( (string)( $gameUser['game_user_role'] ?? '' ) ) ) === 'author'; // ================================================================ // RUNTIME STATE // ================================================================ $state = null; if (!empty($gameUser['state'])) { $decodedState = json_decode( $gameUser['state'], true ); if (is_array($decodedState)) { $state = $decodedState; } } /* * WAYFARER_ENTITY_RUNTIME_STATE_PAGE_RECOVERY_V49 * * Existing development playthroughs should gain Entity state immediately on * page load. This is additive and does not overwrite current runtime values. */ if ($state !== null) { $runtimeRecoveryChanged = wayfarerRuntimeEnsureEntityState( $pdo, (int)$gameId, $state ); /* WAYFARER_PLAYER_TRAITS_PAGE_RECOVERY_V52 */ if ( wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$gameId, $state ) ) { $runtimeRecoveryChanged = true; } if ($runtimeRecoveryChanged) { $encodedRecoveredState = wayfarerRuntimeEncodeState( $state ); $entityRecoveryStmt = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $entityRecoveryStmt->execute([ $encodedRecoveredState, (int)$gameUser['game_user_id'], ]); /* * Keep Author Debug and any later page component on the same snapshot. */ $gameUser['state'] = $encodedRecoveredState; } } // ================================================================ // INITIALIZE GAME // ================================================================ $initializationError = ''; if ( $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'initialize' && $state === null ) { try { wayfarerRuntimeInitializeGame( $pdo, (int)$gameUser['game_user_id'], [ 'player_names' => $_POST['player_names'] ?? [], 'choices' => $_POST['setup_choices'] ?? [], ] ); /* * Reload from persisted state so every play-page component sees the * exact same initialized snapshot. */ header('Location: index.php'); exit; } catch (Throwable $exception) { $initializationError = $exception->getMessage(); } } // ================================================================ // AUTHOR MODE: GAME TRAIT DEFINITIONS // ================================================================ $gameTraitDefinitions = []; $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } foreach ($gameConfig['traits'] ?? [] as $trait) { if (empty($trait['id'])) { continue; } $gameTraitDefinitions[$trait['id']] = $trait; } ?> <?= htmlspecialchars($gameUser['game_name']) ?>

Player:



================================================================================ FILE: _update_backups/v99-20260930-183513/play/assets/play.js ================================================================================ /* |-------------------------------------------------------------------------- | PLAYER RUNTIME CONTROLLER | WAYFARER_ACTION_SCREEN_V39 | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | Runtime modes currently exposed to the player: | | presentation -> Action -> running navigation -> presentation/Action | | Presentation is persisted by PHP. Automatic tick advancement cannot continue | while a presentation is active. | */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; if (!config) { return; } const body = document.body; const tickValue = document.getElementById( 'wayfarer-tick-value' ); const statusValue = document.getElementById( 'wayfarer-tick-status' ); const message = document.getElementById( 'wayfarer-tick-message' ); const onceButton = document.getElementById( 'wayfarer-tick-once' ); const autoButton = document.getElementById( 'wayfarer-tick-auto' ); const authorPauseButton = document.getElementById( 'wayfarer-tick-pause' ); const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); /* WAYFARER_HUD_SURFACES_LIVE_V95 */ const playerHuds = Array.from( document.querySelectorAll( '[data-wayfarer-hud]' ) ); /* WAYFARER_TRAVEL_TIME_HUD_V61 */ const travelTime = document.querySelector( '[data-wayfarer-travel-time]' ); const travelTimeSeparator = document.querySelector( '[data-wayfarer-travel-time-separator]' ); const travelNext = document.querySelector( '[data-wayfarer-travel-next]' ); const continueTravelButton = document.querySelector( '[data-wayfarer-continue-travel]' ); const playerPauseButton = document.querySelector( '[data-wayfarer-pause-travel]' ); /* WAYFARER_PLAYER_TURN_AROUND_UI_V72 */ const turnAroundButton = document.querySelector( '[data-wayfarer-turn-around]' ); /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Action Mode owns one physical card. Inventory, Status, and future * utility/context views temporarily replace the normal action heading/list * inside that card without becoming persisted runtime modes. */ const actionHeading = actionScreen ? actionScreen.querySelector( '.wayfarer-action-heading' ) : null; const actionList = actionScreen ? actionScreen.querySelector( '[data-wayfarer-action-list]' ) : null; const actionSubviewPanels = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ) : []; const actionViewOpeners = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-open-action-view]' ) ) : []; const actionViewClosers = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-close-action-view]' ) ) : []; /* * Persisted narrative presentation. */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); const presentationContinueButton = presentationScreen ? presentationScreen.querySelector( '[data-wayfarer-presentation-continue]' ) : null; let running = false; let requestInFlight = false; let presentationRequestInFlight = false; let storyletRequestInFlight = false; let timeoutId = null; let reloadWhenIdle = false; /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ let requestedTravelDirection = Number( config.initialDirection ) < 0 ? -1 : 1; let presentationActive = Boolean( presentationScreen && !presentationScreen.hidden ); function setMessage( text, isError = false ) { if (!message) { return; } message.textContent = text; message.style.color = isError ? 'red' : ''; } function emitModeEvent() { let mode = 'paused'; if (presentationActive) { mode = 'presentation'; } else if (running) { mode = 'running'; } window.dispatchEvent( new CustomEvent( 'wayfarer:runtime-mode', { detail: { mode: mode } } ) ); /* * Keep the v39 event names available for existing development hooks. */ window.dispatchEvent( new CustomEvent( running ? 'wayfarer:runtime-running' : 'wayfarer:runtime-paused', { detail: { mode: mode } } ) ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Generic in-card view switcher. Future Map / Shop / other views can use * the same data-wayfarer-open-action-view + data-wayfarer-action-view * contract without adding another page-level layout system. */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Resolve available views at the moment navigation occurs instead of * relying on the V48 page-load snapshot. This supports nested Entity * detail screens and future context views added to the same Action card. */ function setActionView(viewName = 'actions') { if (!actionScreen) { return; } const requested = String( viewName || 'actions' ); const showActions = requested === 'actions'; const panels = Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ); let matched = showActions; panels.forEach( function (panel) { const active = panel.dataset .wayfarerActionView === requested; panel.hidden = !active; if (active) { matched = true; } } ); /* * Fail safely if a malformed/removed view name is requested. */ if (!matched) { panels.forEach( function (panel) { panel.hidden = true; } ); if (actionHeading) { actionHeading.hidden = false; } if (actionList) { actionList.hidden = false; } return; } if (actionHeading) { actionHeading.hidden = !showActions; } if (actionList) { actionList.hidden = !showActions; } } function renderControls() { if (statusValue) { statusValue.textContent = presentationActive ? 'Presentation' : ( running ? 'Running' : 'Paused' ); } if (onceButton) { onceButton.disabled = running || requestInFlight || presentationActive; } if (autoButton) { autoButton.disabled = running || requestInFlight || presentationActive; } if (authorPauseButton) { authorPauseButton.disabled = !running; } if (continueTravelButton) { continueTravelButton.disabled = running || requestInFlight || presentationActive; } document .querySelectorAll( '[data-wayfarer-storylet-action-id], ' + '[data-wayfarer-storylet-branch-id]' ) .forEach( function (button) { button.disabled = requestInFlight || presentationRequestInFlight || storyletRequestInFlight || ( button.hasAttribute( 'data-wayfarer-storylet-action-id' ) && (running || presentationActive) ); } ); if (playerPauseButton) { playerPauseButton.disabled = !running; } /* WAYFARER_PLAYER_TURN_AROUND_UI_V72 */ if (turnAroundButton) { turnAroundButton.disabled = running || requestInFlight || presentationActive; } if (actionScreen) { actionScreen.hidden = running || presentationActive; } if (travelControls) { travelControls.hidden = !running || presentationActive; } /* WAYFARER_HUD_SURFACE_VISIBILITY_V95 */ playerHuds.forEach(function (hud) { const surface = String( hud.dataset.wayfarerHudSurface || 'travel' ); if (surface === 'action') { hud.hidden = presentationActive || running; return; } hud.hidden = presentationActive || !running; }); if (presentationScreen) { presentationScreen.hidden = !presentationActive; presentationScreen.setAttribute( 'aria-hidden', presentationActive ? 'false' : 'true' ); } body.classList.toggle( 'wayfarer-runtime-running', running ); body.classList.toggle( 'wayfarer-runtime-paused', !running && !presentationActive ); body.classList.toggle( 'wayfarer-runtime-presentation', presentationActive ); } /* * WAYFARER_LIVE_ACTION_CONTEXT_V46_1 * * Action Mode must reflect the current runtime position, not the page's * original server-rendered context. */ function renderPlayerContext(context) { if ( !context || typeof context !== 'object' ) { return; } const heading = document.querySelector( '[data-wayfarer-action-context-name]' ); const parent = document.querySelector( '[data-wayfarer-action-parent-context]' ); if (heading) { const name = String( context.context_name || 'Actions' ).trim(); heading.textContent = name || 'Actions'; } if (parent) { const parentText = String( context.parent_context || '' ).trim(); parent.textContent = parentText; parent.hidden = parentText === ''; } } /* WAYFARER_TRAVEL_TIME_HUD_RENDER_V61 */ function renderTravelStatus(travel) { if (!travel || typeof travel !== 'object') { return; } const timeLabel = String(travel.time_label || '').trim(); if (travelTime) { travelTime.textContent = timeLabel; travelTime.hidden = timeLabel === ''; } if (travelTimeSeparator) { travelTimeSeparator.hidden = timeLabel === ''; } const nextLabel = String(travel.next_label || '').trim(); if (travelNext) { travelNext.textContent = nextLabel === '' ? '' : 'Next: ' + nextLabel; travelNext.hidden = nextLabel === ''; } } function displayRuntimeValue(value) { if (typeof value === 'boolean') { return value ? 'True' : 'False'; } if ( value === null || value === undefined ) { return 'null'; } if (typeof value === 'object') { return JSON.stringify( value ); } return String( value ); } /* * WAYFARER_STORYLET_ACTIONS_LIVE_V59 * * Tick/presentation APIs return the currently eligible root Manual * Storylets so Action Mode does not depend on the page-load snapshot. */ function renderStoryletActions(actions) { if (!actionScreen) { return; } const host = actionScreen.querySelector( '[data-wayfarer-storylet-actions]' ); if (!host) { return; } const normalized = Array.isArray(actions) ? actions : []; host.innerHTML = ''; normalized.forEach( function (action) { const id = Number( action && action.id ? action.id : 0 ); if (id <= 0) { return; } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-action-button'; button.dataset.wayfarerStoryletActionId = String(id); button.textContent = String( action.label || action.name || 'Storylet' ); host.appendChild( button ); } ); const empty = actionScreen.querySelector( '[data-wayfarer-action-empty]' ); if (empty) { const canTravel = actionScreen.dataset.wayfarerCanTravel === '1'; empty.hidden = canTravel || host.children.length > 0; } renderControls(); } /* * WAYFARER_GAME_TRAVEL_HUD_LIVE_V94 * * HUD components carry a direct runtime Trait address. Tick responses * already include canonical state, so Number and Progress Bar values can * update in place without a separate HUD endpoint. */ function resolveHudTraits(state, scope) { if (!state || typeof state !== 'object') { return null; } if (scope === 'game') { return state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : null; } const match = String(scope || '').match(/^(region|location|entity):([1-9][0-9]*)$/); if (!match) { return null; } const bucket = match[1] === 'region' ? 'regions' : (match[1] === 'location' ? 'locations' : 'entities'); const key = match[2]; return state[bucket] && state[bucket][key] && state[bucket][key].traits && typeof state[bucket][key].traits === 'object' ? state[bucket][key].traits : null; } function formatHudNumber(value) { const number = Number(value); if (!Number.isFinite(number)) { return String(value ?? ''); } return Number.isInteger(number) ? String(number) : String(Math.round(number * 100) / 100); } function renderHudState(state) { if (!playerHuds.length || !state || typeof state !== 'object') { return; } playerHuds.forEach(function (playerHud) { playerHud .querySelectorAll('[data-wayfarer-hud-component]') .forEach(function (component) { const type = String(component.dataset.wayfarerHudType || 'number'); const scope = String(component.dataset.wayfarerHudScope || ''); const sourceId = String(component.dataset.wayfarerHudSourceId || ''); const traits = resolveHudTraits(state, scope); if ( !traits || !Object.prototype.hasOwnProperty.call(traits, sourceId) ) { return; } const value = Number(traits[sourceId]); if (!Number.isFinite(value)) { return; } const valueNode = component.querySelector('[data-wayfarer-hud-value]'); if (valueNode) { valueNode.textContent = formatHudNumber(value); } if (type !== 'progress_bar') { return; } const min = Number(component.dataset.wayfarerHudMin); const max = Number(component.dataset.wayfarerHudMax); if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) { return; } const percent = Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)); const fill = component.querySelector('[data-wayfarer-hud-fill]'); if (fill) { fill.style.width = percent + '%'; } const meter = component.querySelector('[data-wayfarer-hud-meter]'); if (meter) { meter.setAttribute('aria-valuenow', String(value)); } }); }); } function renderRuntimeState(state) { if ( !state || typeof state !== 'object' ) { return; } renderHudState( state ); if ( state.navigation && state.navigation.position !== undefined ) { const position = String( state.navigation.position ); const authorPosition = document.getElementById( 'wayfarer-author-position' ); if (authorPosition) { authorPosition.textContent = position; } } const gameTraits = state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : {}; document .querySelectorAll( '[data-wayfarer-game-trait-value]' ) .forEach( function (cell) { const traitId = cell.dataset .wayfarerGameTraitValue; if ( Object.prototype .hasOwnProperty .call( gameTraits, traitId ) ) { cell.textContent = displayRuntimeValue( gameTraits[traitId] ); } } ); document .querySelectorAll( '[data-wayfarer-region-trait-value]' ) .forEach( function (cell) { const regionId = cell.dataset .wayfarerRegionId; const traitId = cell.dataset .wayfarerRegionTraitValue; const traits = state.regions && state.regions[regionId] && state.regions[regionId].traits && typeof state.regions[regionId].traits === 'object' ? state.regions[regionId].traits : null; if ( traits && Object.prototype .hasOwnProperty .call( traits, traitId ) ) { cell.textContent = displayRuntimeValue( traits[traitId] ); } } ); const debug = document.getElementById( 'wayfarer-runtime-debug' ); if (debug) { debug.textContent = JSON.stringify( state, null, 2 ); } } async function requestTick() { if ( requestInFlight || presentationActive ) { return null; } requestInFlight = true; renderControls(); try { const response = await fetch( config.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ travel_direction: requestedTravelDirection }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Tick endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Tick request failed.' ); } if (tickValue) { tickValue.textContent = String( data.tick ); } renderRuntimeState( data.state ); /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ if ( data.state && data.state.navigation && Object.prototype .hasOwnProperty .call( data.state.navigation, 'direction' ) ) { requestedTravelDirection = Number( data.state.navigation.direction ) < 0 ? -1 : 1; } renderPlayerContext( data.context ); renderTravelStatus( data.travel ); renderStoryletActions( data.actions ); setMessage( 'Tick ' + data.tick + ' saved.' ); if (data.pause === true) { /* * Suppress Action Mode during the brief interval before reload. * The server-rendered presentation will exist after reload. */ presentationActive = true; pauseRuntime( data.pause_reason || 'Travel interrupted.', true ); } return data; } catch (error) { pauseRuntime( error instanceof Error ? error.message : 'Tick request failed.' ); setMessage( error instanceof Error ? error.message : 'Tick request failed.', true ); return null; } finally { requestInFlight = false; renderControls(); if (reloadWhenIdle) { reloadWhenIdle = false; window.location.reload(); } } } async function runAutomaticTick() { if ( !running || presentationActive ) { return; } const result = await requestTick(); if ( !running || presentationActive || result === null ) { return; } timeoutId = window.setTimeout( runAutomaticTick, Number( config.intervalMs ) || 1500 ); } function startRuntime() { if ( running || requestInFlight || presentationActive ) { return; } /* * Travel always leaves inspection/context views and returns the card to * its normal Action view for the next pause. */ setActionView( 'actions' ); running = true; setMessage( 'Automatic advancement started.' ); renderControls(); emitModeEvent(); runAutomaticTick(); } /* * WAYFARER_PRESENTATION_TRANSITION_V63 * * Cover the current DOM before a server-rendered Presentation replaces it. * This prevents the previous Location/Storylet copy from being painted for * a frame during the navigation boundary. */ function beginPresentationTransition() { body.classList.add( 'wayfarer-presentation-transitioning' ); } function pauseRuntime( reason = 'Automatic advancement paused.', reloadForPresentation = false ) { const wasRunning = running; running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } setMessage( reason ); if (wasRunning) { setActionView( 'actions' ); } renderControls(); if (wasRunning) { emitModeEvent(); } if (reloadForPresentation) { /* WAYFARER_PRESENTATION_TRANSITION_V63 */ beginPresentationTransition(); if (requestInFlight) { reloadWhenIdle = true; } else { window.location.reload(); } } } /* * WAYFARER_STORYLET_ACTION_RUNTIME_V59 */ async function requestStorylet( storyletId, entryMode = 'action', parentStoryletId = 0 ) { if ( storyletRequestInFlight || requestInFlight || presentationRequestInFlight ) { return; } storyletId = Number(storyletId || 0); parentStoryletId = Number(parentStoryletId || 0); if (storyletId <= 0) { return; } storyletRequestInFlight = true; renderControls(); try { const response = await fetch( config.storyletEndpoint || 'api/storylet.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, storylet_id: storyletId, entry_mode: entryMode, parent_storylet_id: parentStoryletId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Storylet endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not enter Storylet.' ); } /* * Storylet presentation/Branch markup is server-rendered. Reload * only after the runtime transaction succeeds, so Effects cannot be * repeated by a failed or duplicate browser transition. */ /* WAYFARER_PRESENTATION_TRANSITION_V63 */ beginPresentationTransition(); window.location.reload(); } catch (error) { window.alert( error instanceof Error ? error.message : 'Could not enter Storylet.' ); } finally { storyletRequestInFlight = false; renderControls(); } } async function dismissPresentation() { if ( !presentationActive || !presentationScreen || presentationRequestInFlight ) { return; } presentationRequestInFlight = true; if (presentationContinueButton) { presentationContinueButton.disabled = true; } const presentationType = presentationScreen.dataset .wayfarerPresentationType || ''; const presentationId = Number( presentationScreen.dataset .wayfarerPresentationId || 0 ); try { const response = await fetch( config.presentationEndpoint || 'api/presentation.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, type: presentationType, id: presentationId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Presentation endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not continue.' ); } presentationActive = false; setActionView( 'actions' ); renderRuntimeState( data.state ); renderPlayerContext( data.context ); renderStoryletActions( data.actions ); renderControls(); emitModeEvent(); /* * Place keyboard focus on the first available Action so the * transition works cleanly for keyboard players too. */ const firstAction = actionScreen ? actionScreen.querySelector( 'button:not([disabled]), a[href]' ) : null; if ( firstAction && typeof firstAction.focus === 'function' ) { firstAction.focus(); } } catch (error) { if (presentationContinueButton) { presentationContinueButton.disabled = false; } window.alert( error instanceof Error ? error.message : 'Could not continue.' ); } finally { presentationRequestInFlight = false; /* WAYFARER_PRESENTATION_CONTROL_RESET_V62 * * dismissPresentation() previously refreshed Action controls while * presentationRequestInFlight was still true. Newly-rendered Manual * Storylet buttons were therefore left disabled after a Location or * Storylet presentation was dismissed. Re-render once the request * flag has actually been cleared. */ renderControls(); if ( presentationContinueButton && presentationActive ) { presentationContinueButton.disabled = false; } } } if (onceButton) { onceButton.addEventListener( 'click', async function () { if ( running || presentationActive ) { return; } setMessage(''); await requestTick(); } ); } if (autoButton) { autoButton.addEventListener( 'click', startRuntime ); } if (authorPauseButton) { authorPauseButton.addEventListener( 'click', function () { pauseRuntime( 'Automatic advancement paused.' ); } ); } /* WAYFARER_PLAYER_TURN_AROUND_UI_V72 */ if (turnAroundButton) { turnAroundButton.addEventListener( 'click', function () { requestedTravelDirection = requestedTravelDirection < 0 ? 1 : -1; startRuntime(); } ); } if (continueTravelButton) { continueTravelButton.addEventListener( 'click', startRuntime ); } if (playerPauseButton) { playerPauseButton.addEventListener( 'click', function () { /* * WAYFARER_SMOOTH_MANUAL_PAUSE_V46_2 * * v46.1 keeps Action context current in-place, so manual Pause * no longer needs a full page reload. */ pauseRuntime( 'Travel paused.' ); } ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Ensure V50 detail panels physically belong to the Action card. This is * harmless when the PHP view already placed them correctly, and repairs * any earlier V50 markup that landed just outside the host. */ if (actionScreen) { document .querySelectorAll( '.wayfarer-entity-detail-view' ) .forEach( function (panel) { if ( !actionScreen.contains( panel ) ) { actionScreen.appendChild( panel ); } } ); /* * Delegate navigation from the Action card itself. Any current or * future control using the generic data attributes automatically * participates in the same router. */ actionScreen.addEventListener( 'click', function (event) { const storyletAction = event.target.closest( '[data-wayfarer-storylet-action-id]' ); if ( storyletAction && actionScreen.contains( storyletAction ) ) { if ( running || presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( storyletAction.dataset .wayfarerStoryletActionId, 'action', 0 ); return; } const opener = event.target.closest( '[data-wayfarer-open-action-view]' ); if ( opener && actionScreen.contains( opener ) ) { if ( running || presentationActive ) { return; } event.preventDefault(); setActionView( opener.dataset .wayfarerOpenActionView || 'actions' ); return; } const closer = event.target.closest( '[data-wayfarer-close-action-view]' ); if ( !closer || !actionScreen.contains( closer ) ) { return; } event.preventDefault(); const returnView = closer.dataset .wayfarerReturnActionView || 'actions'; setActionView( returnView ); let focusTarget = null; if (returnView === 'actions') { focusTarget = actionList ? actionList.querySelector( 'button:not([disabled]), a[href]' ) : null; } else { const parentPanel = actionScreen.querySelector( '[data-wayfarer-action-view="' + returnView + '"]' ); focusTarget = parentPanel ? parentPanel.querySelector( '[data-wayfarer-open-action-view], button:not([disabled]), a[href]' ) : null; } if ( focusTarget && typeof focusTarget.focus === 'function' ) { focusTarget.focus(); } } ); } /* WAYFARER_STORYLET_BRANCH_SELECTION_V59 */ document.addEventListener( 'click', function (event) { const branch = event.target.closest( '[data-wayfarer-storylet-branch-id]' ); if (!branch) { return; } if ( !presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( branch.dataset .wayfarerStoryletBranchId, 'branch', branch.dataset .wayfarerStoryletParentId ); } ); if (presentationContinueButton) { presentationContinueButton.addEventListener( 'click', dismissPresentation ); } document.addEventListener( 'keydown', function (event) { if ( !presentationActive || presentationRequestInFlight || storyletRequestInFlight || !presentationContinueButton ) { return; } const target = event.target; if ( target && target.matches && target.matches( 'input, textarea, select, button, a[href], [contenteditable="true"]' ) ) { return; } if ( event.key !== 'Enter' && event.key !== ' ' ) { return; } event.preventDefault(); dismissPresentation(); } ); window.addEventListener( 'pagehide', function () { running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } } ); window.wayfarerRuntimeController = { start: startRuntime, pause: pauseRuntime, dismissPresentation: dismissPresentation, enterStorylet: requestStorylet, isRunning: function () { return running; }, mode: function () { if (presentationActive) { return 'presentation'; } return running ? 'running' : 'paused'; } }; setActionView( 'actions' ); renderControls(); emitModeEvent(); })(); /* |-------------------------------------------------------------------------- | WAYFARER PLAY SHELL v20 |-------------------------------------------------------------------------- | | Reorganizes existing server-rendered nodes into the viewport shell. | Existing nodes are MOVED, not cloned/replaced, so the tick-control | event listeners installed above remain intact. | */ (() => { 'use strict'; if ( document.getElementById( 'wayfarer-play-shell' ) ) { return; } function exactHeading( level, text ) { const headings = document.querySelectorAll( level ); for (const heading of headings) { if ( heading.textContent .replace(/\s+/g, ' ') .trim() === text ) { return heading; } } return null; } function moveRange( start, stopExclusive, destination ) { if ( !start || !destination ) { return; } let node = start; while ( node && node !== stopExclusive ) { const next = node.nextSibling; destination.appendChild( node ); node = next; } } const body = document.body; /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * PHP decides whether Author Mode is permitted. JavaScript only shapes the * interface around that server-side decision. */ const canUseAuthorMode = body.dataset.wayfarerAuthorMode === '1'; body.classList.add( 'wayfarer-play-page' ); const shell = document.createElement( 'div' ); shell.id = 'wayfarer-play-shell'; shell.className = 'wayfarer-play-shell'; const header = document.createElement( 'header' ); header.className = 'wayfarer-play-shell-header'; const title = document.createElement( 'div' ); title.className = 'wayfarer-play-title'; const actions = document.createElement( 'div' ); actions.className = 'wayfarer-shell-actions'; const toggleAuthor = document.createElement( 'button' ); toggleAuthor.type = 'button'; toggleAuthor.className = 'wayfarer-shell-button'; if (canUseAuthorMode) { actions.appendChild( toggleAuthor ); } header.appendChild( title ); if (canUseAuthorMode) { header.appendChild( actions ); } const main = document.createElement( 'main' ); main.className = 'wayfarer-play-main'; const playerPane = document.createElement( 'section' ); playerPane.className = 'wayfarer-player-pane'; const authorPane = document.createElement( 'aside' ); authorPane.className = 'wayfarer-author-pane'; const authorHeader = document.createElement( 'div' ); authorHeader.className = 'wayfarer-author-header'; const authorToolbar = document.createElement( 'div' ); authorToolbar.className = 'wayfarer-author-toolbar'; const tabBar = document.createElement( 'div' ); tabBar.className = 'wayfarer-author-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const authorContent = document.createElement( 'div' ); authorContent.className = 'wayfarer-author-content'; authorPane.appendChild( authorHeader ); authorPane.appendChild( authorToolbar ); authorPane.appendChild( tabBar ); authorPane.appendChild( authorContent ); main.appendChild( playerPane ); if (canUseAuthorMode) { main.appendChild( authorPane ); } else { /* * Keep the player workspace full-width when Author Mode is unavailable. * No Author pane was rendered by PHP and none is attached here. */ shell.classList.add( 'author-collapsed' ); } shell.appendChild( header ); shell.appendChild( main ); body.insertBefore( shell, body.firstChild ); // ------------------------------------------------------------ // Top title/player identity // ------------------------------------------------------------ const pageTitle = Array.from( body.querySelectorAll( ':scope > h1' ) )[0]; if (pageTitle) { const playerIdentity = pageTitle.nextElementSibling; title.appendChild( pageTitle ); if ( playerIdentity && playerIdentity.tagName === 'P' ) { title.appendChild( playerIdentity ); } } // ------------------------------------------------------------ // Player content // ------------------------------------------------------------ /* * WAYFARER_PLAYER_START_SCREEN_V36 * * Before runtime state exists, the start screen owns the full player * workspace and Author Mode is intentionally hidden. */ const startScreen = document.querySelector( '[data-wayfarer-start-screen]' ); if (startScreen) { shell.classList.add( 'is-starting' ); playerPane.appendChild( startScreen ); } const scene = document.getElementById( 'wayfarer-player-scene' ); const gameStateHeading = exactHeading( 'h2', 'Game State' ) || exactHeading( 'h2', 'Game Not Initialized' ); if (gameStateHeading) { const meta = document.createElement( 'div' ); meta.className = 'wayfarer-player-meta'; /* * Move from Game State through the node immediately before the * player scene. This includes initialization messaging/forms but * stops before the actual scene. */ moveRange( gameStateHeading, scene, meta ); playerPane.appendChild( meta ); } if (scene) { playerPane.appendChild( scene ); } /* * WAYFARER_ACTION_SCREEN_V39 * * Action Mode is player-facing and exists independently of Author Mode. */ const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); if (actionScreen) { playerPane.appendChild( actionScreen ); } /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); if (presentationScreen) { playerPane.appendChild( presentationScreen ); } const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); if (travelControls) { playerPane.appendChild( travelControls ); } /* * WAYFARER_HUD_STABLE_BOTTOM_V98 * * Placement semantics: * * Travel-only items live in the middle Travel area, directly below the * Travel status/controls card. * * Action-only items live in the persistent bottom HUD area but are visible * only while stopped/on the Action screen. * * Travel & Action items also live in that same persistent bottom HUD area * and remain there in both modes. They do not move into the Travel middle * area when travel starts. */ document .querySelectorAll('[data-wayfarer-hud]') .forEach(function (persistentHud) { const surface = String( persistentHud.dataset .wayfarerHudSurface || 'travel' ); if ( surface === 'travel' && travelControls && travelControls.parentNode ) { travelControls.insertAdjacentElement( 'afterend', persistentHud ); return; } /* * Both the Action surface and the persistent Both surface belong * at the bottom of the player pane. Visibility logic determines * whether Action-only rows disappear while traveling. */ playerPane.appendChild( persistentHud ); }); // ------------------------------------------------------------ // Author header // ------------------------------------------------------------ const authorHeading = exactHeading( 'h2', 'Author Mode' ); if (authorHeading) { const builderLinkBlock = authorHeading.nextElementSibling; authorHeader.appendChild( authorHeading ); if ( builderLinkBlock && builderLinkBlock.tagName === 'P' ) { authorHeader.appendChild( builderLinkBlock ); } } // ------------------------------------------------------------ // Runtime controls // ------------------------------------------------------------ const tickPanel = document.getElementById( 'wayfarer-tick-test' ); if (tickPanel) { authorToolbar.appendChild( tickPanel ); } // ------------------------------------------------------------ // Author tabs // ------------------------------------------------------------ /* WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 */ const panelDefinitions = [ { key: 'state', label: 'State' }, { key: 'debug', label: 'Debug' } ]; function activateTab(key) { document .querySelectorAll( '.wayfarer-author-tab' ) .forEach( function (button) { const active = button.dataset .wayfarerAuthorTab === key; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); authorContent .querySelectorAll( '.wayfarer-author-tab-panel' ) .forEach( function (panel) { panel.classList.toggle( 'is-active', panel.dataset .wayfarerAuthorPanel === key ); } ); try { window.localStorage.setItem( 'wayfarerAuthorTab', key ); } catch (error) { // Storage is optional. } } for (const definition of panelDefinitions) { const panel = document.querySelector( '[data-wayfarer-author-panel="' + definition.key + '"]' ); if (!panel) { continue; } authorContent.appendChild( panel ); const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-author-tab'; button.dataset.wayfarerAuthorTab = definition.key; button.setAttribute( 'role', 'tab' ); button.textContent = definition.label; button.addEventListener( 'click', function () { activateTab( definition.key ); } ); tabBar.appendChild( button ); } let initialTab = 'state'; try { let savedTab = window.localStorage.getItem( 'wayfarerAuthorTab' ); if ( savedTab === 'region' ) { savedTab = 'state'; window.localStorage.setItem( 'wayfarerAuthorTab', 'state' ); } if ( savedTab && authorContent.querySelector( '[data-wayfarer-author-panel="' + savedTab + '"]' ) ) { initialTab = savedTab; } } catch (error) { // Storage is optional. } activateTab( initialTab ); // ------------------------------------------------------------ // Hide / Show Author Mode // WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 // ------------------------------------------------------------ if (canUseAuthorMode) { function renderAuthorToggle() { const collapsed = shell.classList.contains( 'author-collapsed' ); toggleAuthor.textContent = collapsed ? 'Show Author' : 'Hide Author'; toggleAuthor.setAttribute( 'aria-expanded', collapsed ? 'false' : 'true' ); } toggleAuthor.addEventListener( 'click', function () { shell.classList.toggle( 'author-collapsed' ); try { window.localStorage.setItem( 'wayfarerAuthorCollapsed', shell.classList.contains( 'author-collapsed' ) ? '1' : '0' ); } catch (error) { // Storage is optional. } renderAuthorToggle(); } ); try { if ( window.localStorage.getItem( 'wayfarerAuthorCollapsed' ) === '1' ) { shell.classList.add( 'author-collapsed' ); } } catch (error) { // Storage is optional. } renderAuthorToggle(); } else { /* * Player role is not merely a hidden Author interface. Keep the shell * permanently in player-only layout. */ shell.classList.add( 'author-collapsed' ); } // ------------------------------------------------------------ // Remove otherwise-empty top-level HRs left by the old document flow. // ------------------------------------------------------------ body .querySelectorAll( ':scope > hr' ) .forEach( function (rule) { rule.remove(); } ); })(); /* |-------------------------------------------------------------------------- | ENDING RESTART | WAYFARER_ENDING_RESTART_V63 |-------------------------------------------------------------------------- */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; const restartButton = document.querySelector( '[data-wayfarer-restart-game]' ); if ( !config || !restartButton ) { return; } let requestInFlight = false; restartButton.addEventListener( 'click', async function () { if (requestInFlight) { return; } if ( !window.confirm( 'Start this game over? Your current playthrough progress will be erased.' ) ) { return; } requestInFlight = true; restartButton.disabled = true; const originalText = restartButton.textContent; restartButton.textContent = 'Starting over…'; try { const response = await fetch( config.restartEndpoint || 'api/restart.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Restart endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not restart the game.' ); } document.body.classList.add( 'wayfarer-presentation-transitioning' ); window.location.href = 'index.php'; } catch (error) { requestInFlight = false; restartButton.disabled = false; restartButton.textContent = originalText; window.alert( error instanceof Error ? error.message : 'Could not restart the game.' ); } } ); })(); ================================================================================ FILE: _update_backups/v99-20260930-183513/play/includes/hud.php ================================================================================ (string)($component['id'] ?? ''), 'type' => $type, 'scope' => (string)$source['scope'], 'source_id' => (string)$source['target_id'], 'label' => $label, 'value' => $value, 'display_value' => $displayValue, 'show_value' => $type === 'number' ? true : !empty($component['show_value']), 'min' => null, 'max' => null, 'percent' => null, ]; if ($type === 'progress_bar') { if (empty($source['bounded'])) { continue; } $min = (float)$source['min']; $max = (float)$source['max']; $percent = (($value - $min) / ($max - $min)) * 100; $percent = max(0, min(100, $percent)); $row['min'] = $min; $row['max'] = $max; $row['percent'] = $percent; } $rendered[] = $row; } return $rendered; }; $renderHudSurface = static function ( string $surface, array $components ): void { if (!$components) { return; } $surfaceLabel = $surface === 'action' ? 'Action status' : 'Travel status'; $surfaceClass = $surface === 'action' ? 'wayfarer-action-hud' : 'wayfarer-travel-hud'; ?> prepare(" SELECT g.id AS game_id, g.name AS game_name, g.slug AS game_slug, g.instructions AS game_instructions, g.player_slots AS player_slots, g.config AS game_config, gu.id AS game_user_id, gu.role AS game_user_role, gu.state, u.username, u.display_name FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id INNER JOIN wf_users u ON u.id = gu.user_id WHERE gu.game_id = ? AND gu.user_id = ? LIMIT 1 "); $stmt->execute([ $gameId, $userId ]); $gameUser = $stmt->fetch(); if (!$gameUser) { die('Game user not found.'); } /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * Author permission belongs to the user/game relationship, not to the user * globally. wf_game_users.role is therefore the canonical source. */ $canUseAuthorMode = strtolower( trim( (string)( $gameUser['game_user_role'] ?? '' ) ) ) === 'author'; // ================================================================ // RUNTIME STATE // ================================================================ $state = null; if (!empty($gameUser['state'])) { $decodedState = json_decode( $gameUser['state'], true ); if (is_array($decodedState)) { $state = $decodedState; } } /* * WAYFARER_ENTITY_RUNTIME_STATE_PAGE_RECOVERY_V49 * * Existing development playthroughs should gain Entity state immediately on * page load. This is additive and does not overwrite current runtime values. */ if ($state !== null) { $runtimeRecoveryChanged = wayfarerRuntimeEnsureEntityState( $pdo, (int)$gameId, $state ); /* WAYFARER_PLAYER_TRAITS_PAGE_RECOVERY_V52 */ if ( wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$gameId, $state ) ) { $runtimeRecoveryChanged = true; } if ($runtimeRecoveryChanged) { $encodedRecoveredState = wayfarerRuntimeEncodeState( $state ); $entityRecoveryStmt = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $entityRecoveryStmt->execute([ $encodedRecoveredState, (int)$gameUser['game_user_id'], ]); /* * Keep Author Debug and any later page component on the same snapshot. */ $gameUser['state'] = $encodedRecoveredState; } } // ================================================================ // INITIALIZE GAME // ================================================================ $initializationError = ''; if ( $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'initialize' && $state === null ) { try { wayfarerRuntimeInitializeGame( $pdo, (int)$gameUser['game_user_id'], [ 'player_names' => $_POST['player_names'] ?? [], 'choices' => $_POST['setup_choices'] ?? [], ] ); /* * Reload from persisted state so every play-page component sees the * exact same initialized snapshot. */ header('Location: index.php'); exit; } catch (Throwable $exception) { $initializationError = $exception->getMessage(); } } // ================================================================ // AUTHOR MODE: GAME TRAIT DEFINITIONS // ================================================================ $gameTraitDefinitions = []; $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } foreach ($gameConfig['traits'] ?? [] as $trait) { if (empty($trait['id'])) { continue; } $gameTraitDefinitions[$trait['id']] = $trait; } ?> <?= htmlspecialchars($gameUser['game_name']) ?>

Player:



================================================================================ FILE: _v10_backups/20260922-155150/admin/builder-refactored.php ================================================================================ prepare(" SELECT id, name FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; // ================================================================ // SAVE / DELETE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { /* * Media Library associations are applied only after the authored * object has saved successfully and therefore has a stable ID. */ $savedRecordId = (int)$saveResult['id']; $mediaResult = applyBuilderMediaSelections( $pdo, $gameId, $definition['table'], $savedRecordId, $_POST, $_FILES ); if (!$mediaResult['saved']) { /* * The authored object itself has already saved. Keep the * editor on that record and report the media failure * accurately rather than pretending the object save failed. */ $id = $savedRecordId; $record['id'] = $savedRecordId; $error = 'Record saved, but media was not changed: ' . $mediaResult['error']; } else { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder


+ New




← Games

================================================================================ FILE: _v10_backups/20260922-155150/assets/js/builder.js ================================================================================ const wayfarerConditionData = window.wayfarerConditionData || {}; function updateConditionalConfigFields() { document .querySelectorAll('[data-config-field]') .forEach(function (container) { const relationshipName = container.dataset.conditionRelationship; const requiredValue = container.dataset.conditionEquals; if (!relationshipName) { container.style.display = ''; return; } const relationshipSelect = document.querySelector( '[data-relationship="' + relationshipName + '"]' ); if (!relationshipSelect) { container.style.display = 'none'; return; } const selectedId = relationshipSelect.value; const actualValue = wayfarerConditionData[relationshipName]?.[selectedId]; container.style.display = actualValue === requiredValue ? '' : 'none'; }); } document .querySelectorAll('[data-relationship]') .forEach(function (select) { select.addEventListener( 'change', updateConditionalConfigFields ); }); // ================================================================ // SYSTEM TRAIT GROUPS // ================================================================ function updateSystemTraitGroups(container) { const selectorField = container.dataset.selectorField; if (!selectorField) { return; } const selector = document.querySelector( '[name="' + selectorField + '"]' ); if (!selector) { return; } const selectedGroup = selector.value; container .querySelectorAll('[data-system-trait-group]') .forEach(function (group) { const active = group.dataset.systemTraitGroup === selectedGroup; group.style.display = active ? '' : 'none'; group .querySelectorAll('input, select, textarea') .forEach(function (input) { input.disabled = !active; }); }); } document .querySelectorAll('[data-system-trait-settings]') .forEach(function (container) { const selectorField = container.dataset.selectorField; const selector = selectorField ? document.querySelector( '[name="' + selectorField + '"]' ) : null; if (selector) { selector.addEventListener( 'change', function () { updateSystemTraitGroups(container); } ); } updateSystemTraitGroups(container); }); // ================================================================ // COLLECTION SORTING // ================================================================ document .querySelectorAll('[data-sort-column]') .forEach(function (button) { button.addEventListener('click', function () { const table = button.closest('[data-collection-table]'); if (!table) { return; } const tbody = table.querySelector('tbody'); const columnName = button.dataset.sortColumn; const sortType = button.dataset.sortType || 'text'; const currentDirection = button.dataset.sortDirection || 'none'; const nextDirection = currentDirection === 'asc' ? 'desc' : 'asc'; table .querySelectorAll('[data-sort-column]') .forEach(function (otherButton) { otherButton.dataset.sortDirection = 'none'; }); button.dataset.sortDirection = nextDirection; const rows = Array.from(tbody.querySelectorAll('tr')); rows.sort(function (a, b) { const aCell = a.querySelector( '[data-column="' + columnName + '"]' ); const bCell = b.querySelector( '[data-column="' + columnName + '"]' ); const aValue = aCell?.dataset.sortValue ?? ''; const bValue = bCell?.dataset.sortValue ?? ''; let comparison = 0; if (sortType === 'number') { const aNumber = aValue === '' ? Number.POSITIVE_INFINITY : Number(aValue); const bNumber = bValue === '' ? Number.POSITIVE_INFINITY : Number(bValue); comparison = aNumber - bNumber; } else { comparison = aValue.localeCompare( bValue, undefined, { numeric: true, sensitivity: 'base' } ); } return nextDirection === 'asc' ? comparison : -comparison; }); rows.forEach(function (row) { tbody.appendChild(row); }); }); }); updateConditionalConfigFields(); // ================================================================ // MEDIA LIBRARY CONTROLS // ================================================================ function updateBuilderMediaSlot(slotContainer) { const checkedAction = slotContainer.querySelector( 'input[type="radio"][name^="media_action"]:checked' ); const action = checkedAction ? checkedAction.value : 'none'; const existingSelect = slotContainer.querySelector( '[data-media-existing]' ); if (existingSelect) { existingSelect.disabled = action !== 'existing'; } slotContainer .querySelectorAll( '[data-media-upload-fields] input' ) .forEach(function (input) { input.disabled = action !== 'upload'; }); } document .querySelectorAll('[data-media-slot]') .forEach(function (slotContainer) { slotContainer .querySelectorAll( 'input[type="radio"][name^="media_action"]' ) .forEach(function (radio) { radio.addEventListener( 'change', function () { updateBuilderMediaSlot( slotContainer ); } ); }); updateBuilderMediaSlot( slotContainer ); }); ================================================================================ FILE: _v11_backups/20260922-155930/admin/builder-refactored.php ================================================================================ prepare(" SELECT id, name FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; // ================================================================ // SAVE / DELETE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { /* * Media Library associations are applied only after the authored * object has saved successfully and therefore has a stable ID. */ $savedRecordId = (int)$saveResult['id']; $mediaResult = applyBuilderMediaSelections( $pdo, $gameId, $definition['table'], $savedRecordId, $_POST, $_FILES ); if (!$mediaResult['saved']) { /* * The authored object itself has already saved. Keep the * editor on that record and report the media failure * accurately rather than pretending the object save failed. */ $id = $savedRecordId; $record['id'] = $savedRecordId; $error = 'Record saved, but media was not changed: ' . $mediaResult['error']; } else { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder

================================================================================ FILE: _v11_backups/20260922-155930/admin/builder.php ================================================================================ prepare(" SELECT id, name FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // HELPER: GET RECORDS AVAILABLE FOR A RELATIONSHIP // ================================================================ function getRelationshipOptions( PDO $pdo, array $relationship, int $gameId ): array { $table = $relationship['table']; $valueField = $relationship['value_field']; $labelField = $relationship['label_field']; $gameField = $relationship['game_field']; $sql = " SELECT `$valueField` AS option_value, `$labelField` AS option_label FROM `$table` WHERE `$gameField` = ? ORDER BY `$labelField` "; $stmt = $pdo->prepare($sql); $stmt->execute([$gameId]); return $stmt->fetchAll(); } // ================================================================ // HELPER: BUILD GAME-SCOPE QUERY // ================================================================ function buildScopeQuery(array $definition): array { $table = $definition['table']; $scope = $definition['scope']; // Directly belongs to game if ($scope['type'] === 'game') { return [ 'from' => "`$table`", 'where' => "`$table`.`{$scope['field']}` = ?" ]; } // Belongs to something that belongs to game $scopeTable = $scope['scope_table']; $scopeField = $scope['field']; $scopeKey = $scope['scope_key']; $gameField = $scope['game_field']; return [ 'from' => " `$table` INNER JOIN `$scopeTable` ON `$table`.`$scopeField` = `$scopeTable`.`$scopeKey` ", 'where' => "`$scopeTable`.`$gameField` = ?" ]; } // ================================================================ // HELPER: GET RELATED RECORD VALUE // ================================================================ function getRelatedFieldValue( PDO $pdo, array $condition, $relationshipValue ) { if ($relationshipValue === '' || $relationshipValue === null) { return null; } $table = $condition['source_table']; $sourceKey = $condition['source_key']; $sourceField = $condition['source_field']; $sql = " SELECT `$sourceField` FROM `$table` WHERE `$sourceKey` = ? LIMIT 1 "; $stmt = $pdo->prepare($sql); $stmt->execute([$relationshipValue]); $value = $stmt->fetchColumn(); return $value === false ? null : $value; } // ================================================================ // HELPER: DOES A CONFIG FIELD APPLY? // ================================================================ function configFieldApplies( PDO $pdo, array $field, array $record ): bool { // No condition means always show/use the field. if (empty($field['condition'])) { return true; } $condition = $field['condition']; $relationshipName = $condition['relationship'] ?? null; if (!$relationshipName) { return false; } $relationshipValue = $record[$relationshipName] ?? ''; $sourceValue = getRelatedFieldValue( $pdo, $condition, $relationshipValue ); return (string)$sourceValue === (string)$condition['equals']; } // ================================================================ // DEFAULT RECORD // ================================================================ $record = [ 'id' => null ]; foreach ($relationships as $fieldName => $relationship) { $record[$fieldName] = ''; } foreach ($fields as $fieldName => $field) { $record[$fieldName] = $field['default'] ?? ''; } // ================================================================ // CONFIG VALUES // ================================================================ $configValues = []; foreach ($configFields as $fieldName => $field) { $configValues[$fieldName] = $field['default'] ?? ''; } $traits = []; // ================================================================ // LOAD EXISTING RECORD // ================================================================ if ($id) { $scopeQuery = buildScopeQuery($definition); $sql = " SELECT `$table`.* FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($sql); $stmt->execute([ $id, $gameId ]); $existing = $stmt->fetch(); if (!$existing) { die('Record not found.'); } $record = array_merge($record, $existing); // ------------------------------------------------------------ // LOAD CONFIG JSON // ------------------------------------------------------------ if (!empty($existing['config'])) { $decodedConfig = json_decode( $existing['config'], true ); if (is_array($decodedConfig)) { foreach ($configFields as $fieldName => $field) { if (array_key_exists($fieldName, $decodedConfig)) { $configValues[$fieldName] = $decodedConfig[$fieldName]; } } if ( !empty($definition['traits']) && isset($decodedConfig['traits']) && is_array($decodedConfig['traits']) ) { $traits = $decodedConfig['traits']; } } } } // ================================================================ // SAVE // ================================================================ $error = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $postedId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; // ------------------------------------------------------------ // READ RELATIONSHIPS // ------------------------------------------------------------ foreach ($relationships as $fieldName => $relationship) { $value = $_POST[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } // ------------------------------------------------------------ // READ NORMAL FIELDS // ------------------------------------------------------------ foreach ($fields as $fieldName => $field) { $value = $_POST[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } // ------------------------------------------------------------ // READ CONFIG FIELDS // ------------------------------------------------------------ foreach ($configFields as $fieldName => $field) { $value = $_POST['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } // ------------------------------------------------------------ // VALIDATE RELATIONSHIPS // ------------------------------------------------------------ foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } // ------------------------------------------------------------ // READ TRAITS // ------------------------------------------------------------ if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost( $_POST['traits'] ?? [] ); $traits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } // ------------------------------------------------------------ // VALIDATE NORMAL FIELDS // ------------------------------------------------------------ if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } // ------------------------------------------------------------ // VERIFY RELATIONSHIPS BELONG TO THIS GAME // ------------------------------------------------------------ if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } // ------------------------------------------------------------ // VALIDATE ACTIVE CONFIG FIELDS // ------------------------------------------------------------ if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies( $pdo, $field, $record ); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && $field['type'] === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } // ============================================================ // WRITE RECORD // ============================================================ if ($error === '') { $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } // -------------------------------------------------------- // BUILD CONFIG JSON // -------------------------------------------------------- $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode( $record['config'], true ); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if ($field['type'] === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } // Add traits to config when this object type supports them. if (!empty($definition['traits'])) { $configToSave['traits'] = $traits; } // -------------------------------------------------------- // CONFIG COLUMN // -------------------------------------------------------- if (!empty($configFields) || !empty($definition['traits'])) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } // -------------------------------------------------------- // UPDATE // -------------------------------------------------------- if ($postedId) { $scopeQuery = buildScopeQuery($definition); // Verify record belongs to current game. $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([ $postedId, $gameId ]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; // Blank optional normal number becomes NULL. if ( isset($fields[$fieldName]) && $fields[$fieldName]['type'] === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } // -------------------------------------------------------- // INSERT // -------------------------------------------------------- else { $columns = []; $placeholders = []; $values = []; // Direct game ownership if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = "?"; $values[] = $gameId; } // Relationship fields foreach ( $relationships as $fieldName => $relationship ) { $columns[] = "`$fieldName`"; $placeholders[] = "?"; $values[] = $record[$fieldName]; } // Normal fields foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = "?"; $value = $record[$fieldName]; if ( $field['type'] === 'number' && $value === '' ) { $value = null; } $values[] = $value; } // Config JSON if (!empty($configFields)) { $columns[] = "`config`"; $placeholders[] = "?"; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } // -------------------------------------------------------- // RETURN TO LIST // -------------------------------------------------------- header( 'Location: builder.php?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } // ================================================================ // LOAD RECORD LIST // ================================================================ $scopeQuery = buildScopeQuery($definition); /* * If this builder type defines list columns, load the complete * authored record. This gives the collection renderer access to * ordinary fields, relationship IDs, and config JSON. * * Types without list_columns retain the original lightweight query. */ if (!empty($listColumns)) { $sql = " SELECT `$table`.* FROM {$scopeQuery['from']} WHERE {$scopeQuery['where']} ORDER BY `$table`.`$listField` "; } else { $sql = " SELECT `$table`.`id`, `$table`.`$listField` FROM {$scopeQuery['from']} WHERE {$scopeQuery['where']} ORDER BY `$table`.`$listField` "; } $stmt = $pdo->prepare($sql); $stmt->execute([$gameId]); $records = $stmt->fetchAll(); // ================================================================ // BUILD COLLECTION DISPLAY VALUES // ================================================================ if (!empty($listColumns)) { foreach ($records as &$item) { $item['_list'] = []; // Decode config once for this record. $itemConfig = []; if (!empty($item['config'])) { $decoded = json_decode( $item['config'], true ); if (is_array($decoded)) { $itemConfig = $decoded; } } foreach ($listColumns as $columnName => $column) { $source = $column['source'] ?? 'field'; // ---------------------------------------------------- // ORDINARY DATABASE FIELD // ---------------------------------------------------- if ($source === 'field') { $fieldName = $column['field'] ?? $columnName; $item['_list'][$columnName] = $item[$fieldName] ?? ''; } // ---------------------------------------------------- // CONFIG JSON VALUE // ---------------------------------------------------- elseif ($source === 'config') { $configField = $column['field'] ?? $columnName; $item['_list'][$columnName] = $itemConfig[$configField] ?? ''; } // ---------------------------------------------------- // RELATIONSHIP LABEL // ---------------------------------------------------- elseif ($source === 'relationship') { $relationshipName = $column['relationship'] ?? ''; $item['_list'][$columnName] = ''; if ( $relationshipName !== '' && isset($relationships[$relationshipName]) ) { $relationship = $relationships[$relationshipName]; $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $labelField = $relationship['label_field']; $relationshipId = $item[$relationshipName] ?? null; if ($relationshipId) { $relationshipSql = " SELECT `$labelField` FROM `$relationshipTable` WHERE `$valueField` = ? LIMIT 1 "; $relationshipStmt = $pdo->prepare($relationshipSql); $relationshipStmt->execute([ $relationshipId ]); $relationshipLabel = $relationshipStmt->fetchColumn(); if ($relationshipLabel !== false) { $item['_list'][$columnName] = $relationshipLabel; } } } } } } unset($item); } // ================================================================ // LOAD RELATIONSHIP OPTIONS // ================================================================ $relationshipOptions = []; foreach ($relationships as $fieldName => $relationship) { $relationshipOptions[$fieldName] = getRelationshipOptions( $pdo, $relationship, $gameId ); } // ================================================================ // BUILD CONDITION DATA FOR BROWSER // ================================================================ $conditionData = []; foreach ($configFields as $fieldName => $field) { if (empty($field['condition'])) { continue; } $condition = $field['condition']; $relationshipName = $condition['relationship']; if (!isset($relationships[$relationshipName])) { continue; } $relationship = $relationships[$relationshipName]; $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sourceField = $condition['source_field']; $sql = " SELECT `$valueField` AS record_id, `$sourceField` AS condition_value FROM `$relationshipTable` WHERE `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([$gameId]); foreach ($stmt->fetchAll() as $row) { $conditionData[$relationshipName][ (string)$row['record_id'] ] = (string)$row['condition_value']; } } ?> <?= htmlspecialchars($game['name']) ?> - Wayfarer Builder

Wayfarer Builder


+ New


$relationship): ?>

$field): ?>

$field): ?>

data-condition-relationship="" data-condition-equals="" >


Traits

Traits are values associated with this that can change during play.


No have been created yet.

$column): ?> $column): ?>

← Games

================================================================================ FILE: _v12_backups/20260922-160410/admin/games/index.php ================================================================================ query(" SELECT id, name, slug, status, updated_at FROM wf_games ORDER BY name "); $games = $stmt->fetchAll(); ?> Wayfarer

Wayfarer

Games

+ New Game

No games have been created yet.

Name Slug Status Last Updated Actions
Open Builder | Game Settings
================================================================================ FILE: _v13_1_backups/20260922-160954/admin/games/edit.php ================================================================================ null, 'name' => '', 'slug' => '', 'description' => '', 'instructions' => '', 'player_slots' => 0, 'config' => null, 'status' => 'draft' ]; $traits = []; $config = []; $error = ''; // ================================================================ // LOAD EXISTING GAME // ================================================================ if (isset($_GET['id'])) { $stmt = $pdo->prepare(" SELECT * FROM wf_games WHERE id = ? "); $stmt->execute([(int)$_GET['id']]); $existingGame = $stmt->fetch(); if (!$existingGame) { die('Game not found.'); } $game = array_merge($game, $existingGame); if (!empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } if ( isset($config['traits']) && is_array($config['traits']) ) { $traits = $config['traits']; } } // ================================================================ // SAVE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $game['id'] = !empty($_POST['id']) ? (int)$_POST['id'] : null; $game['name'] = trim($_POST['name'] ?? ''); $game['slug'] = trim($_POST['slug'] ?? ''); $game['description'] = trim($_POST['description'] ?? ''); $game['instructions'] = trim($_POST['instructions'] ?? ''); $game['player_slots'] = max(0, (int)($_POST['player_slots'] ?? 0)); $game['status'] = $_POST['status'] ?? 'draft'; // ------------------------------------------------------------ // BUILD TRAITS // ------------------------------------------------------------ $traitResult = buildTraitsFromPost( $_POST['traits'] ?? [] ); $traits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } // ------------------------------------------------------------ // VALIDATE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['name'] === '') { $error = 'Game name is required.'; } elseif ($game['slug'] === '') { $error = 'Slug is required.'; } } // ------------------------------------------------------------ // BUILD CONFIG JSON // ------------------------------------------------------------ if ($error === '') { /* * Preserve any other configuration already stored for the game. * Traits own only the "traits" section of config. */ if (empty($config) && !empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } $startingRegionId = !empty($_POST['starting_region_id']) ? (int)$_POST['starting_region_id'] : null; if ($startingRegionId !== null) { $config['starting_region_id'] = $startingRegionId; } else { unset($config['starting_region_id']); } $config['traits'] = $traits; $game['config'] = json_encode( $config, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ($game['config'] === false) { $error = 'Unable to encode game configuration.'; } } // ------------------------------------------------------------ // WRITE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['id']) { $stmt = $pdo->prepare(" UPDATE wf_games SET name = ?, slug = ?, description = ?, instructions = ?, player_slots = ?, config = ?, status = ? WHERE id = ? "); $stmt->execute([ $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'], $game['id'] ]); } else { $uuid = bin2hex(random_bytes(16)); $stmt = $pdo->prepare(" INSERT INTO wf_games ( uuid, name, slug, description, instructions, player_slots, config, status ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $uuid, $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'] ]); $game['id'] = (int)$pdo->lastInsertId(); } header('Location: index.php'); exit; } } // ================================================================ // LOAD REGIONS FOR STARTING REGION // ================================================================ $regions = []; if (!empty($game['id'])) { $regionStmt = $pdo->prepare(" SELECT id, name FROM wf_regions WHERE game_id = ? ORDER BY name "); $regionStmt->execute([ $game['id'] ]); $regions = $regionStmt->fetchAll(); } ?> <?= $game['id'] ? 'Edit Game' : 'New Game' ?> - Wayfarer

← Games


Example: stormgate


0 = no player-entered names; 1 = one; 5 = five, etc.



Traits

Traits are game-defined values that can change during play.



================================================================================ FILE: _v13_1_backups/20260922-160954/assets/css/admin.css ================================================================================ /* |-------------------------------------------------------------------------- | Wayfarer Admin Theme |-------------------------------------------------------------------------- | Shared visual language for Wayfarer administrative pages. */ :root { --wf-border: #d8dbe2; --wf-border-strong: #b8bdc7; --wf-surface: #ffffff; --wf-surface-soft: #f6f7f9; --wf-page: #f3f4f6; --wf-text: #20242a; --wf-muted: #667085; --wf-accent: #2f5ea8; --wf-accent-hover: #244b88; --wf-accent-soft: #edf3fc; --wf-success-soft: #eef8f1; --wf-success-text: #28603a; --wf-radius: 8px; --wf-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); } .wayfarer-admin-page { box-sizing: border-box; margin: 0; padding: 28px; background: var(--wf-page); color: var(--wf-text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; font-size: 15px; line-height: 1.45; } .wayfarer-admin-page *, .wayfarer-admin-page *::before, .wayfarer-admin-page *::after { box-sizing: border-box; } .wayfarer-admin-page > * { max-width: 1180px; margin-left: auto; margin-right: auto; } .wayfarer-admin-page > h1 { margin-top: 0; margin-bottom: 2px; font-size: 2rem; line-height: 1.15; } .wayfarer-admin-page > h2 { margin-top: 0; margin-bottom: 20px; color: var(--wf-muted); font-size: 1rem; font-weight: 500; } .wayfarer-admin-page a { color: var(--wf-accent); } .wayfarer-admin-page a:hover { color: var(--wf-accent-hover); } /* ------------------------------------------------------------------ GAMES INDEX ------------------------------------------------------------------ */ .wayfarer-games-page > p { margin-top: 0; margin-bottom: 18px; } .admin-primary-action { display: inline-block; padding: 9px 14px; border: 1px solid #214b86; border-radius: 6px; background: var(--wf-accent); color: #ffffff !important; text-decoration: none; font-weight: 600; box-shadow: var(--wf-shadow); } .admin-primary-action:hover { background: var(--wf-accent-hover); color: #ffffff !important; } .admin-table { width: 100%; overflow: hidden; border: 1px solid var(--wf-border); border-collapse: separate; border-spacing: 0; border-radius: var(--wf-radius); background: var(--wf-surface); box-shadow: var(--wf-shadow); } .admin-table th, .admin-table td { padding: 12px 14px; border: 0; border-bottom: 1px solid var(--wf-border); text-align: left; vertical-align: middle; } .admin-table th { background: var(--wf-surface-soft); color: #404650; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.045em; text-transform: uppercase; } .admin-table tr:last-child td { border-bottom: 0; } .admin-table tbody tr:hover td { background: #fafbfc; } .games-table td:first-child { font-weight: 700; } .games-table td:nth-child(2) { color: var(--wf-muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9rem; } .games-table td:nth-child(3) { text-transform: capitalize; } .games-table td:last-child { white-space: nowrap; } .games-table td:last-child a { display: inline-block; margin: 2px 5px 2px 0; padding: 6px 9px; border: 1px solid var(--wf-border-strong); border-radius: 5px; background: var(--wf-surface); color: var(--wf-text); text-decoration: none; font-size: 0.9rem; font-weight: 600; } .games-table td:last-child a:hover { border-color: #9cabc0; background: var(--wf-accent-soft); color: var(--wf-accent-hover); } /* Make the primary builder action visually stronger. */ .games-table td:last-child a:first-child { border-color: #a9bddc; background: var(--wf-accent-soft); color: var(--wf-accent); } /* ------------------------------------------------------------------ RESPONSIVE ------------------------------------------------------------------ */ @media (max-width: 760px) { .wayfarer-admin-page { padding: 16px; } .games-table { display: block; overflow-x: auto; white-space: nowrap; } } @media (max-width: 520px) { .wayfarer-admin-page > h1 { font-size: 1.65rem; } .admin-table th, .admin-table td { padding: 10px; } } ================================================================================ FILE: _v14_backups/20260922-163121/play/index.php ================================================================================ prepare(" SELECT g.id AS game_id, g.name AS game_name, g.slug AS game_slug, g.config AS game_config, gu.id AS game_user_id, gu.role AS game_user_role, gu.state, u.username, u.display_name FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id INNER JOIN wf_users u ON u.id = gu.user_id WHERE gu.game_id = ? AND gu.user_id = ? LIMIT 1 "); $stmt->execute([ $gameId, $userId ]); $gameUser = $stmt->fetch(); if (!$gameUser) { die('Game user not found.'); } // ================================================================ // RUNTIME STATE // ================================================================ $state = null; if (!empty($gameUser['state'])) { $decodedState = json_decode( $gameUser['state'], true ); if (is_array($decodedState)) { $state = $decodedState; } } // ================================================================ // INITIALIZE GAME // ================================================================ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'initialize' && $state === null ) { // ------------------------------------------------------------ // LOAD AUTHORED GAME CONFIG // ------------------------------------------------------------ $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } // ------------------------------------------------------------ // INITIALIZE GAME TRAITS // ------------------------------------------------------------ $gameTraits = []; foreach ($gameConfig['traits'] ?? [] as $trait) { if ( empty($trait['id']) || !array_key_exists('initial', $trait) ) { continue; } $gameTraits[$trait['id']] = $trait['initial']; } // ------------------------------------------------------------ // BUILD INITIAL RUNTIME STATE // ------------------------------------------------------------ $state = [ 'game' => [ 'traits' => $gameTraits ] ]; // ------------------------------------------------------------ // SAVE RUNTIME STATE // ------------------------------------------------------------ $stateJson = json_encode( $state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $stmt = $pdo->prepare(" UPDATE wf_game_users SET state = ? WHERE id = ? "); $stmt->execute([ $stateJson, $gameUser['game_user_id'] ]); // Reload so refresh cannot repeat the POST. header('Location: index.php'); exit; } // ================================================================ // AUTHOR MODE: GAME TRAIT DEFINITIONS // ================================================================ $gameTraitDefinitions = []; $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } foreach ($gameConfig['traits'] ?? [] as $trait) { if (empty($trait['id'])) { continue; } $gameTraitDefinitions[$trait['id']] = $trait; } ?> <?= htmlspecialchars($gameUser['game_name']) ?>

Player:


Game Not Initialized

This game-user record exists, but no runtime state has been created yet.

Game State

Runtime state has been initialized.


Author Mode

Open Wayfarer Builder

Game Traits

No game traits are currently stored.

$value): ?>
Trait Current Value Type
Unknown Trait

Runtime Debug

================================================================================ FILE: _v15_1_backups/20260922-164209/play/includes/runtime.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT id FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $requestedId ]); $id = $stmt->fetchColumn(); if ($id !== false) { return (int)$id; } throw new RuntimeException( 'The requested game-user record does not exist.' ); } $id = $pdo ->query( " SELECT id FROM wf_game_users ORDER BY id LIMIT 1 " ) ->fetchColumn(); if ($id === false) { throw new RuntimeException( 'No wf_game_users record exists.' ); } return (int)$id; } /** * Load one game-user runtime state. */ function wayfarerRuntimeLoad( PDO $pdo, int $gameUserId ): array { $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'state' => $state, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /** * Advance exactly one tick. * * This is intentionally the entire v14 game engine: * * state.runtime.tick += 1 * * The transaction and row lock are already in place so later work can add * movement/effects without changing the request architecture. */ function wayfarerRuntimeAdvanceTick( PDO $pdo, int $gameUserId ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } $currentTick = (int)( $state['runtime']['tick'] ?? 0 ); $state['runtime']['tick'] = $currentTick + 1; $state['runtime']['last_tick_at'] = gmdate('c'); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'tick' => (int)$state['runtime']['tick'], 'state' => $state, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /* |-------------------------------------------------------------------------- | WAYFARER_RUNTIME_V15 |-------------------------------------------------------------------------- | | Starting Region + applicable Trait helpers. | */ /** * Decode authored config JSON safely. */ function wayfarerRuntimeDecodeConfig($rawConfig): array { if (is_array($rawConfig)) { return $rawConfig; } if ($rawConfig === null || $rawConfig === '') { return []; } $decoded = json_decode( (string)$rawConfig, true ); return is_array($decoded) ? $decoded : []; } /** * Return only active authored Trait definitions. */ function wayfarerRuntimeActiveTraitDefinitions( array $config ): array { $traits = $config['traits'] ?? []; if (!is_array($traits)) { return []; } $active = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( array_key_exists( 'active', $trait ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { /* * System Traits created by the generic builder may have a * stable system key even when an older stored record did not * receive an ordinary generated tr_... id. */ $traitId = trim( (string)( $trait['system_key'] ?? '' ) ); } if ($traitId === '') { continue; } $trait['_runtime_id'] = $traitId; $active[] = $trait; } return $active; } /** * Initial runtime value for an authored Trait. */ function wayfarerRuntimeTraitInitialValue( array $trait ) { if ( array_key_exists( 'initial', $trait ) ) { return $trait['initial']; } /* * System Traits use the same stored Trait collection. The builder * writes their current authored starting value as initial; this * fallback simply keeps malformed/older data deterministic. */ $type = (string)( $trait['type'] ?? '' ); if ( $type === 'boolean' || $type === 'binary' ) { return 0; } if ($type === 'number') { return 0; } return null; } /** * Recursively find plausible authored starting-Region values. * * This is a compatibility reader for the existing Game Settings config. * Runtime itself normalizes the result to navigation.region_id. */ function wayfarerRuntimeFindStartRegionCandidates( array $value, array $path = [] ): array { $candidates = []; foreach ($value as $key => $child) { $keyString = (string)$key; $childPath = array_merge( $path, [$keyString] ); if (is_array($child)) { $candidates = array_merge( $candidates, wayfarerRuntimeFindStartRegionCandidates( $child, $childPath ) ); continue; } if ( !is_int($child) && !is_float($child) && !is_string($child) ) { continue; } $scalar = trim( (string)$child ); if ( $scalar === '' || !ctype_digit($scalar) ) { continue; } $fullPath = strtolower( implode( '.', $childPath ) ); $keyLower = strtolower( $keyString ); $explicitNames = [ 'starting_region_id', 'start_region_id', 'initial_region_id', 'starting_region', 'start_region', 'initial_region', ]; $isExplicit = in_array( $keyLower, $explicitNames, true ); $pathLooksLikeStart = ( strpos( $fullPath, 'start' ) !== false || strpos( $fullPath, 'initial' ) !== false ) && strpos( $fullPath, 'region' ) !== false; if ( $isExplicit || $pathLooksLikeStart ) { $candidates[] = [ 'path' => implode( '.', $childPath ), 'region_id' => (int)$scalar, ]; } } return $candidates; } /** * Resolve the authored starting Region and verify it belongs to this Game. */ function wayfarerRuntimeResolveStartingRegion( PDO $pdo, int $gameId, array $gameConfig ): ?array { $candidates = wayfarerRuntimeFindStartRegionCandidates( $gameConfig ); if (!$candidates) { return null; } $seen = []; foreach ($candidates as $candidate) { $regionId = (int)$candidate['region_id']; if ( $regionId <= 0 || isset($seen[$regionId]) ) { continue; } $seen[$regionId] = true; $stmt = $pdo->prepare( " SELECT id, game_id, name, navigation_type, config, status FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); if ($region) { $region['_starting_region_config_path'] = $candidate['path']; return $region; } } return null; } /** * Load a specific Region belonging to the current Game. */ function wayfarerRuntimeLoadRegion( PDO $pdo, int $gameId, int $regionId ): ?array { if ($regionId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT id, game_id, name, navigation_type, config, status FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); return $region ?: null; } /** * Initialize any missing Trait values without resetting existing runtime * values. Returns true when state changed. */ function wayfarerRuntimeInitializeTraitValues( array &$targetValues, array $traitDefinitions ): bool { $changed = false; foreach ($traitDefinitions as $trait) { $traitId = (string)( $trait['_runtime_id'] ?? '' ); if ($traitId === '') { continue; } if ( array_key_exists( $traitId, $targetValues ) ) { continue; } $targetValues[$traitId] = wayfarerRuntimeTraitInitialValue( $trait ); $changed = true; } return $changed; } /** * Ensure the playthrough has its first applicable gameplay context: * * Game * + current Region * * Starting Region is consulted ONLY when runtime has no current Region. */ function wayfarerRuntimeEnsureInitialContext( PDO $pdo, int $gameUserId ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $gameUser = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$gameUser) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $gameId = (int)$gameUser['game_id']; $state = wayfarerRuntimeDecodeState( $gameUser['state'] ); $gameStmt = $pdo->prepare( " SELECT id, name, config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $game = $gameStmt->fetch( PDO::FETCH_ASSOC ); if (!$game) { throw new RuntimeException( 'Game not found for runtime state.' ); } $gameConfig = wayfarerRuntimeDecodeConfig( $game['config'] ); $gameTraitDefinitions = wayfarerRuntimeActiveTraitDefinitions( $gameConfig ); $changed = false; if ( !isset($state['game']) || !is_array($state['game']) ) { $state['game'] = []; $changed = true; } if ( !isset($state['game']['traits']) || !is_array($state['game']['traits']) ) { $state['game']['traits'] = []; $changed = true; } if ( wayfarerRuntimeInitializeTraitValues( $state['game']['traits'], $gameTraitDefinitions ) ) { $changed = true; } // ------------------------------------------------------------ // CURRENT / STARTING REGION // ------------------------------------------------------------ if ( !isset($state['navigation']) || !is_array($state['navigation']) ) { $state['navigation'] = []; $changed = true; } $currentRegionId = (int)( $state['navigation']['region_id'] ?? 0 ); $region = null; $startingRegionPath = null; if ($currentRegionId > 0) { $region = wayfarerRuntimeLoadRegion( $pdo, $gameId, $currentRegionId ); } /* * Runtime has no valid current Region yet, so use authored start. */ if (!$region) { $region = wayfarerRuntimeResolveStartingRegion( $pdo, $gameId, $gameConfig ); if ($region) { $currentRegionId = (int)$region['id']; $state['navigation']['region_id'] = $currentRegionId; if ( !array_key_exists( 'position', $state['navigation'] ) ) { $state['navigation']['position'] = 0; } $startingRegionPath = $region['_starting_region_config_path'] ?? null; $changed = true; } } // ------------------------------------------------------------ // CURRENT REGION TRAITS // ------------------------------------------------------------ $regionTraitDefinitions = []; if ($region) { $regionConfig = wayfarerRuntimeDecodeConfig( $region['config'] ); $regionTraitDefinitions = wayfarerRuntimeActiveTraitDefinitions( $regionConfig ); if ( !isset($state['regions']) || !is_array($state['regions']) ) { $state['regions'] = []; $changed = true; } $regionKey = (string)$currentRegionId; if ( !isset($state['regions'][$regionKey]) || !is_array( $state['regions'][$regionKey] ) ) { $state['regions'][$regionKey] = []; $changed = true; } if ( !isset( $state['regions'][$regionKey]['traits'] ) || !is_array( $state['regions'][$regionKey]['traits'] ) ) { $state['regions'][$regionKey]['traits'] = []; $changed = true; } if ( wayfarerRuntimeInitializeTraitValues( $state['regions'][$regionKey]['traits'], $regionTraitDefinitions ) ) { $changed = true; } } // ------------------------------------------------------------ // SAVE ONLY WHEN SOMETHING CHANGED // ------------------------------------------------------------ if ($changed) { $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ wayfarerRuntimeEncodeState( $state ), $gameUserId, ]); } $pdo->commit(); return [ 'game_user_id' => (int)$gameUser['id'], 'game_id' => $gameId, 'user_id' => (int)$gameUser['user_id'], 'game' => $game, 'game_config' => $gameConfig, 'game_trait_definitions' => $gameTraitDefinitions, 'region' => $region, 'region_trait_definitions' => $regionTraitDefinitions, 'starting_region_config_path' => $startingRegionPath, 'state' => $state, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Build display rows for the currently applicable Trait scopes. * * v15: * - Game * - current Region */ function wayfarerRuntimeApplicableTraitRows( array $context ): array { $rows = []; $state = $context['state'] ?? []; foreach ( $context['game_trait_definitions'] ?? [] as $trait ) { $traitId = (string)( $trait['_runtime_id'] ?? '' ); if ($traitId === '') { continue; } $rows[] = [ 'scope' => 'Game', 'name' => (string)( $trait['name'] ?? $trait['label'] ?? $traitId ), 'value' => $state['game']['traits'][$traitId] ?? null, 'type' => (string)( $trait['type'] ?? '' ), ]; } $region = $context['region'] ?? null; if ($region) { $regionId = (int)$region['id']; $regionKey = (string)$regionId; $scopeLabel = 'Region: ' . ( (string)( $region['name'] ?? ('#' . $regionId) ) ); foreach ( $context['region_trait_definitions'] ?? [] as $trait ) { $traitId = (string)( $trait['_runtime_id'] ?? '' ); if ($traitId === '') { continue; } $rows[] = [ 'scope' => $scopeLabel, 'name' => (string)( $trait['name'] ?? $trait['label'] ?? $traitId ), 'value' => $state['regions'][$regionKey]['traits'][$traitId] ?? null, 'type' => (string)( $trait['type'] ?? '' ), ]; } } return $rows; } ================================================================================ FILE: _v15_backups/20260922-163837/play/includes/runtime.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT id FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $requestedId ]); $id = $stmt->fetchColumn(); if ($id !== false) { return (int)$id; } throw new RuntimeException( 'The requested game-user record does not exist.' ); } $id = $pdo ->query( " SELECT id FROM wf_game_users ORDER BY id LIMIT 1 " ) ->fetchColumn(); if ($id === false) { throw new RuntimeException( 'No wf_game_users record exists.' ); } return (int)$id; } /** * Load one game-user runtime state. */ function wayfarerRuntimeLoad( PDO $pdo, int $gameUserId ): array { $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'state' => $state, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /** * Advance exactly one tick. * * This is intentionally the entire v14 game engine: * * state.runtime.tick += 1 * * The transaction and row lock are already in place so later work can add * movement/effects without changing the request architecture. */ function wayfarerRuntimeAdvanceTick( PDO $pdo, int $gameUserId ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } $currentTick = (int)( $state['runtime']['tick'] ?? 0 ); $state['runtime']['tick'] = $currentTick + 1; $state['runtime']['last_tick_at'] = gmdate('c'); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'tick' => (int)$state['runtime']['tick'], 'state' => $state, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: _v15_backups/20260922-163837/play/index.php ================================================================================ prepare(" SELECT g.id AS game_id, g.name AS game_name, g.slug AS game_slug, g.config AS game_config, gu.id AS game_user_id, gu.role AS game_user_role, gu.state, u.username, u.display_name FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id INNER JOIN wf_users u ON u.id = gu.user_id WHERE gu.game_id = ? AND gu.user_id = ? LIMIT 1 "); $stmt->execute([ $gameId, $userId ]); $gameUser = $stmt->fetch(); if (!$gameUser) { die('Game user not found.'); } // ================================================================ // RUNTIME STATE // ================================================================ $state = null; if (!empty($gameUser['state'])) { $decodedState = json_decode( $gameUser['state'], true ); if (is_array($decodedState)) { $state = $decodedState; } } // ================================================================ // INITIALIZE GAME // ================================================================ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'initialize' && $state === null ) { // ------------------------------------------------------------ // LOAD AUTHORED GAME CONFIG // ------------------------------------------------------------ $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } // ------------------------------------------------------------ // INITIALIZE GAME TRAITS // ------------------------------------------------------------ $gameTraits = []; foreach ($gameConfig['traits'] ?? [] as $trait) { if ( empty($trait['id']) || !array_key_exists('initial', $trait) ) { continue; } $gameTraits[$trait['id']] = $trait['initial']; } // ------------------------------------------------------------ // BUILD INITIAL RUNTIME STATE // ------------------------------------------------------------ $state = [ 'game' => [ 'traits' => $gameTraits ] ]; // ------------------------------------------------------------ // SAVE RUNTIME STATE // ------------------------------------------------------------ $stateJson = json_encode( $state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $stmt = $pdo->prepare(" UPDATE wf_game_users SET state = ? WHERE id = ? "); $stmt->execute([ $stateJson, $gameUser['game_user_id'] ]); // Reload so refresh cannot repeat the POST. header('Location: index.php'); exit; } // ================================================================ // AUTHOR MODE: GAME TRAIT DEFINITIONS // ================================================================ $gameTraitDefinitions = []; $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } foreach ($gameConfig['traits'] ?? [] as $trait) { if (empty($trait['id'])) { continue; } $gameTraitDefinitions[$trait['id']] = $trait; } ?> <?= htmlspecialchars($gameUser['game_name']) ?>

Player:


Game Not Initialized

This game-user record exists, but no runtime state has been created yet.

Game State

Runtime state has been initialized.


Author Mode

Open Wayfarer Builder

Game Traits

No game traits are currently stored.

$value): ?>
Trait Current Value Type
Unknown Trait

Runtime Debug

================================================================================ FILE: _v16_3_backups/20260922-205324/play/includes/runtime.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT id FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $requestedId ]); $id = $stmt->fetchColumn(); if ($id !== false) { return (int)$id; } throw new RuntimeException( 'The requested game-user record does not exist.' ); } $id = $pdo ->query( " SELECT id FROM wf_game_users ORDER BY id LIMIT 1 " ) ->fetchColumn(); if ($id === false) { throw new RuntimeException( 'No wf_game_users record exists.' ); } return (int)$id; } /** * Load one game-user runtime state. */ function wayfarerRuntimeLoad( PDO $pdo, int $gameUserId ): array { $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'state' => $state, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /** * Advance exactly one tick. * * This is intentionally the entire v14 game engine: * * state.runtime.tick += 1 * * The transaction and row lock are already in place so later work can add * movement/effects without changing the request architecture. */ function wayfarerRuntimeAdvanceTick( PDO $pdo, int $gameUserId ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } $currentTick = (int)( $state['runtime']['tick'] ?? 0 ); $state['runtime']['tick'] = $currentTick + 1; $state['runtime']['last_tick_at'] = gmdate('c'); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'tick' => (int)$state['runtime']['tick'], 'state' => $state, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: _v16_backups/20260922-164742/play/index.php ================================================================================ prepare(" SELECT g.id AS game_id, g.name AS game_name, g.slug AS game_slug, g.config AS game_config, gu.id AS game_user_id, gu.role AS game_user_role, gu.state, u.username, u.display_name FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id INNER JOIN wf_users u ON u.id = gu.user_id WHERE gu.game_id = ? AND gu.user_id = ? LIMIT 1 "); $stmt->execute([ $gameId, $userId ]); $gameUser = $stmt->fetch(); if (!$gameUser) { die('Game user not found.'); } // ================================================================ // RUNTIME STATE // ================================================================ $state = null; if (!empty($gameUser['state'])) { $decodedState = json_decode( $gameUser['state'], true ); if (is_array($decodedState)) { $state = $decodedState; } } // ================================================================ // INITIALIZE GAME // ================================================================ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'initialize' && $state === null ) { // ------------------------------------------------------------ // LOAD AUTHORED GAME CONFIG // ------------------------------------------------------------ $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } // ------------------------------------------------------------ // INITIALIZE GAME TRAITS // ------------------------------------------------------------ $gameTraits = []; foreach ($gameConfig['traits'] ?? [] as $trait) { if ( empty($trait['id']) || !array_key_exists('initial', $trait) ) { continue; } $gameTraits[$trait['id']] = $trait['initial']; } // ------------------------------------------------------------ // BUILD INITIAL RUNTIME STATE // ------------------------------------------------------------ $state = [ 'game' => [ 'traits' => $gameTraits ] ]; // ------------------------------------------------------------ // SAVE RUNTIME STATE // ------------------------------------------------------------ $stateJson = json_encode( $state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $stmt = $pdo->prepare(" UPDATE wf_game_users SET state = ? WHERE id = ? "); $stmt->execute([ $stateJson, $gameUser['game_user_id'] ]); // Reload so refresh cannot repeat the POST. header('Location: index.php'); exit; } // ================================================================ // AUTHOR MODE: GAME TRAIT DEFINITIONS // ================================================================ $gameTraitDefinitions = []; $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } foreach ($gameConfig['traits'] ?? [] as $trait) { if (empty($trait['id'])) { continue; } $gameTraitDefinitions[$trait['id']] = $trait; } ?> <?= htmlspecialchars($gameUser['game_name']) ?>

Player:


Game Not Initialized

This game-user record exists, but no runtime state has been created yet.

Game State

Runtime state has been initialized.


Author Mode

Open Wayfarer Builder

Game Traits

No game traits are currently stored.

$value): ?>
Trait Current Value Type
Unknown Trait

Runtime Debug

================================================================================ FILE: _v17_1_backups/20260922-213922/play/index.php ================================================================================ prepare(" SELECT g.id AS game_id, g.name AS game_name, g.slug AS game_slug, g.config AS game_config, gu.id AS game_user_id, gu.role AS game_user_role, gu.state, u.username, u.display_name FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id INNER JOIN wf_users u ON u.id = gu.user_id WHERE gu.game_id = ? AND gu.user_id = ? LIMIT 1 "); $stmt->execute([ $gameId, $userId ]); $gameUser = $stmt->fetch(); if (!$gameUser) { die('Game user not found.'); } // ================================================================ // RUNTIME STATE // ================================================================ $state = null; if (!empty($gameUser['state'])) { $decodedState = json_decode( $gameUser['state'], true ); if (is_array($decodedState)) { $state = $decodedState; } } // ================================================================ // INITIALIZE GAME // ================================================================ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'initialize' && $state === null ) { // ------------------------------------------------------------ // LOAD AUTHORED GAME CONFIG // ------------------------------------------------------------ $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } // ------------------------------------------------------------ // INITIALIZE GAME TRAITS // ------------------------------------------------------------ $gameTraits = []; foreach ($gameConfig['traits'] ?? [] as $trait) { if ( empty($trait['id']) || !array_key_exists('initial', $trait) ) { continue; } $gameTraits[$trait['id']] = $trait['initial']; } // ------------------------------------------------------------ // BUILD INITIAL RUNTIME STATE // ------------------------------------------------------------ $state = [ 'game' => [ 'traits' => $gameTraits ] ]; // ------------------------------------------------------------ // SAVE RUNTIME STATE // ------------------------------------------------------------ $stateJson = json_encode( $state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $stmt = $pdo->prepare(" UPDATE wf_game_users SET state = ? WHERE id = ? "); $stmt->execute([ $stateJson, $gameUser['game_user_id'] ]); // Reload so refresh cannot repeat the POST. header('Location: index.php'); exit; } // ================================================================ // AUTHOR MODE: GAME TRAIT DEFINITIONS // ================================================================ $gameTraitDefinitions = []; $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } foreach ($gameConfig['traits'] ?? [] as $trait) { if (empty($trait['id'])) { continue; } $gameTraitDefinitions[$trait['id']] = $trait; } ?> <?= htmlspecialchars($gameUser['game_name']) ?>

Player:


Game Not Initialized

This game-user record exists, but no runtime state has been created yet.

Game State

Runtime state has been initialized.


Author Mode

Open Wayfarer Builder

Game Traits

No game traits are currently stored.

$value): ?>
Trait Current Value Type
Unknown Trait

Runtime Debug

================================================================================ FILE: _v17_backups/20260922-174520/play/index.php ================================================================================ prepare(" SELECT g.id AS game_id, g.name AS game_name, g.slug AS game_slug, g.config AS game_config, gu.id AS game_user_id, gu.role AS game_user_role, gu.state, u.username, u.display_name FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id INNER JOIN wf_users u ON u.id = gu.user_id WHERE gu.game_id = ? AND gu.user_id = ? LIMIT 1 "); $stmt->execute([ $gameId, $userId ]); $gameUser = $stmt->fetch(); if (!$gameUser) { die('Game user not found.'); } // ================================================================ // RUNTIME STATE // ================================================================ $state = null; if (!empty($gameUser['state'])) { $decodedState = json_decode( $gameUser['state'], true ); if (is_array($decodedState)) { $state = $decodedState; } } // ================================================================ // INITIALIZE GAME // ================================================================ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'initialize' && $state === null ) { // ------------------------------------------------------------ // LOAD AUTHORED GAME CONFIG // ------------------------------------------------------------ $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } // ------------------------------------------------------------ // INITIALIZE GAME TRAITS // ------------------------------------------------------------ $gameTraits = []; foreach ($gameConfig['traits'] ?? [] as $trait) { if ( empty($trait['id']) || !array_key_exists('initial', $trait) ) { continue; } $gameTraits[$trait['id']] = $trait['initial']; } // ------------------------------------------------------------ // BUILD INITIAL RUNTIME STATE // ------------------------------------------------------------ $state = [ 'game' => [ 'traits' => $gameTraits ] ]; // ------------------------------------------------------------ // SAVE RUNTIME STATE // ------------------------------------------------------------ $stateJson = json_encode( $state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $stmt = $pdo->prepare(" UPDATE wf_game_users SET state = ? WHERE id = ? "); $stmt->execute([ $stateJson, $gameUser['game_user_id'] ]); // Reload so refresh cannot repeat the POST. header('Location: index.php'); exit; } // ================================================================ // AUTHOR MODE: GAME TRAIT DEFINITIONS // ================================================================ $gameTraitDefinitions = []; $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } foreach ($gameConfig['traits'] ?? [] as $trait) { if (empty($trait['id'])) { continue; } $gameTraitDefinitions[$trait['id']] = $trait; } ?> <?= htmlspecialchars($gameUser['game_name']) ?>

Player:


Game Not Initialized

This game-user record exists, but no runtime state has been created yet.

Game State

Runtime state has been initialized.


Author Mode

Open Wayfarer Builder

Game Traits

No game traits are currently stored.

$value): ?>
Trait Current Value Type
Unknown Trait

Runtime Debug

================================================================================ FILE: _v18_1_backups/20260922-175439/admin/builder.php ================================================================================ prepare(" SELECT id, name FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; // ================================================================ // SAVE / DELETE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { /* * Media Library associations are applied only after the authored * object has saved successfully and therefore has a stable ID. */ $savedRecordId = (int)$saveResult['id']; $mediaResult = applyBuilderMediaSelections( $pdo, $gameId, $definition['table'], $savedRecordId, $_POST, $_FILES ); if (!$mediaResult['saved']) { /* * The authored object itself has already saved. Keep the * editor on that record and report the media failure * accurately rather than pretending the object save failed. */ $id = $savedRecordId; $record['id'] = $savedRecordId; $error = 'Record saved, but media was not changed: ' . $mediaResult['error']; } else { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder

================================================================================ FILE: _v18_1_backups/20260922-175439/assets/js/builder.js ================================================================================ const wayfarerConditionData = window.wayfarerConditionData || {}; function updateConditionalConfigFields() { document .querySelectorAll('[data-config-field]') .forEach(function (container) { const relationshipName = container.dataset.conditionRelationship; const requiredValue = container.dataset.conditionEquals; if (!relationshipName) { container.style.display = ''; return; } const relationshipSelect = document.querySelector( '[data-relationship="' + relationshipName + '"]' ); if (!relationshipSelect) { container.style.display = 'none'; return; } const selectedId = relationshipSelect.value; const actualValue = wayfarerConditionData[relationshipName]?.[selectedId]; container.style.display = actualValue === requiredValue ? '' : 'none'; }); } document .querySelectorAll('[data-relationship]') .forEach(function (select) { select.addEventListener( 'change', updateConditionalConfigFields ); }); // ================================================================ // SYSTEM TRAIT GROUPS // ================================================================ function updateSystemTraitGroups(container) { const selectorField = container.dataset.selectorField; if (!selectorField) { return; } const selector = document.querySelector( '[name="' + selectorField + '"]' ); if (!selector) { return; } const selectedGroup = selector.value; container .querySelectorAll('[data-system-trait-group]') .forEach(function (group) { const active = group.dataset.systemTraitGroup === selectedGroup; group.style.display = active ? '' : 'none'; group .querySelectorAll('input, select, textarea') .forEach(function (input) { input.disabled = !active; }); }); } document .querySelectorAll('[data-system-trait-settings]') .forEach(function (container) { const selectorField = container.dataset.selectorField; const selector = selectorField ? document.querySelector( '[name="' + selectorField + '"]' ) : null; if (selector) { selector.addEventListener( 'change', function () { updateSystemTraitGroups(container); } ); } updateSystemTraitGroups(container); }); // ================================================================ // COLLECTION SORTING // ================================================================ document .querySelectorAll('[data-sort-column]') .forEach(function (button) { button.addEventListener('click', function () { const table = button.closest('[data-collection-table]'); if (!table) { return; } const tbody = table.querySelector('tbody'); const columnName = button.dataset.sortColumn; const sortType = button.dataset.sortType || 'text'; const currentDirection = button.dataset.sortDirection || 'none'; const nextDirection = currentDirection === 'asc' ? 'desc' : 'asc'; table .querySelectorAll('[data-sort-column]') .forEach(function (otherButton) { otherButton.dataset.sortDirection = 'none'; }); button.dataset.sortDirection = nextDirection; const rows = Array.from(tbody.querySelectorAll('tr')); rows.sort(function (a, b) { const aCell = a.querySelector( '[data-column="' + columnName + '"]' ); const bCell = b.querySelector( '[data-column="' + columnName + '"]' ); const aValue = aCell?.dataset.sortValue ?? ''; const bValue = bCell?.dataset.sortValue ?? ''; let comparison = 0; if (sortType === 'number') { const aNumber = aValue === '' ? Number.POSITIVE_INFINITY : Number(aValue); const bNumber = bValue === '' ? Number.POSITIVE_INFINITY : Number(bValue); comparison = aNumber - bNumber; } else { comparison = aValue.localeCompare( bValue, undefined, { numeric: true, sensitivity: 'base' } ); } return nextDirection === 'asc' ? comparison : -comparison; }); rows.forEach(function (row) { tbody.appendChild(row); }); }); }); updateConditionalConfigFields(); // ================================================================ // MEDIA LIBRARY CONTROLS // ================================================================ function updateBuilderMediaSlot(slotContainer) { const checkedAction = slotContainer.querySelector( 'input[type="radio"][name^="media_action"]:checked' ); const action = checkedAction ? checkedAction.value : 'none'; const existingSelect = slotContainer.querySelector( '[data-media-existing]' ); if (existingSelect) { existingSelect.disabled = action !== 'existing'; } slotContainer .querySelectorAll( '[data-media-upload-fields] input' ) .forEach(function (input) { input.disabled = action !== 'upload'; }); } document .querySelectorAll('[data-media-slot]') .forEach(function (slotContainer) { slotContainer .querySelectorAll( 'input[type="radio"][name^="media_action"]' ) .forEach(function (radio) { radio.addEventListener( 'change', function () { updateBuilderMediaSlot( slotContainer ); } ); }); updateBuilderMediaSlot( slotContainer ); }); // ================================================================ // AUTHORED OBJECT EDITOR TABS // ================================================================ function initializeBuilderEditorTabs() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } /* * The editor component has one primary save form. The separate delete * form, when present, lives later in the editor pane and is deliberately * left outside the tab system. */ const form = Array .from(editor.children) .find(function (child) { return child.tagName === 'FORM'; }); if ( !form || form.dataset.builderTabbed === '1' ) { return; } form.dataset.builderTabbed = '1'; form.classList.add( 'builder-object-form' ); const originalChildren = Array.from( form.children ); const panels = { basics: document.createElement('div'), media: document.createElement('div'), traits: document.createElement('div') }; panels.basics.className = 'builder-tab-panel'; panels.media.className = 'builder-tab-panel'; panels.traits.className = 'builder-tab-panel'; panels.basics.dataset.builderPanel = 'basics'; panels.media.dataset.builderPanel = 'media'; panels.traits.dataset.builderPanel = 'traits'; let section = 'basics'; let saveContainer = null; let hasMedia = false; let hasTraits = false; originalChildren.forEach( function (child) { /* * Keep hidden record identifiers directly in the form. */ if ( child.tagName === 'INPUT' && child.type === 'hidden' ) { return; } /* * Save becomes a stable footer beneath the tab panels. */ const submitButton = child.querySelector ? child.querySelector( 'button[type="submit"], input[type="submit"]' ) : null; if (submitButton) { saveContainer = child; return; } /* * Ignore the separator immediately before a tab heading. * Other HR elements are harmless but unnecessary inside the * cleaned-up editor. */ if (child.tagName === 'HR') { child.remove(); return; } if (child.tagName === 'H3') { const heading = child.textContent .trim() .toLowerCase(); if (heading === 'media') { section = 'media'; hasMedia = true; child.remove(); return; } if (heading === 'traits') { section = 'traits'; hasTraits = true; child.remove(); return; } } /* * Config fields belong to Basics even though the old v9 Media * markup happens to precede them in the source template. */ if ( child.hasAttribute && child.hasAttribute( 'data-config-field' ) ) { section = 'basics'; } /* * A Media fieldset is definitive even if future markup changes * around the Media heading. */ if ( child.hasAttribute && child.hasAttribute( 'data-media-slot' ) ) { section = 'media'; hasMedia = true; } panels[section] .appendChild( child ); } ); /* * If Traits exist, anything moved after the Traits heading belongs there. * System traits do not use that heading and therefore stay in Basics. */ if ( panels.traits.children.length > 0 ) { hasTraits = true; } if ( panels.media.children.length > 0 ) { hasMedia = true; } const tabBar = document.createElement( 'div' ); tabBar.className = 'builder-editor-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const availablePanels = [ { key: 'basics', label: 'Basics', show: true }, { key: 'media', label: 'Media', show: hasMedia }, { key: 'traits', label: 'Traits', show: hasTraits } ].filter( function (item) { return item.show; } ); function activatePanel(panelKey) { tabBar .querySelectorAll( '[data-builder-tab]' ) .forEach( function (button) { const active = button.dataset.builderTab === panelKey; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); Object .entries(panels) .forEach( function (entry) { const key = entry[0]; const panel = entry[1]; panel.hidden = key !== panelKey; } ); } availablePanels.forEach( function (item) { const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'builder-editor-tab'; button.dataset.builderTab = item.key; button.setAttribute( 'role', 'tab' ); button.textContent = item.label; button.addEventListener( 'click', function () { activatePanel( item.key ); } ); tabBar.appendChild( button ); } ); /* * Insert the new structure after hidden inputs so all form fields remain * part of the same form and submit exactly as before. */ form.appendChild( tabBar ); availablePanels.forEach( function (item) { form.appendChild( panels[item.key] ); } ); if (saveContainer) { saveContainer.classList.add( 'builder-save-bar' ); form.appendChild( saveContainer ); } activatePanel( 'basics' ); } initializeBuilderEditorTabs(); // ================================================================ // REGION TICK EFFECTS TAB / CONTROLS v18 // ================================================================ function initializeRegionTickEffects() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } const wrapper = editor.querySelector( '[data-builder-tick-effects]' ); if (!wrapper) { return; } if ( wrapper.dataset.tickEffectsReady === '1' ) { return; } wrapper.dataset.tickEffectsReady = '1'; // ------------------------------------------------------------ // ROW ADD / REMOVE // ------------------------------------------------------------ const list = wrapper.querySelector( '[data-tick-effects-list]' ); const addButton = wrapper.querySelector( '[data-add-tick-effect]' ); const template = wrapper.querySelector( '[data-tick-effect-template]' ); let nextIndex = 0; wrapper .querySelectorAll( '[data-tick-effect-row]' ) .forEach( function (row) { const index = Number( row.dataset.tickIndex ); if ( Number.isFinite(index) && index >= nextIndex ) { nextIndex = index + 1; } } ); function updateLinearTargetAvailability() { const navigationSelect = editor.querySelector( 'select[name="navigation_type"]' ); const navigationType = navigationSelect ? navigationSelect.value : wrapper.dataset.navigationType; const linear = navigationType === 'linear'; wrapper .querySelectorAll( '[data-linear-navigation-target]' ) .forEach( function (option) { option.disabled = !linear; } ); if (!linear) { wrapper .querySelectorAll( '[data-tick-effect-target]' ) .forEach( function (select) { if ( select.value === 'navigation:position' ) { select.value = ''; } } ); } } if ( addButton && list && template ) { addButton.addEventListener( 'click', function () { const html = template.innerHTML.replace( /__INDEX__/g, String( nextIndex++ ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const row = temporary.firstElementChild; if (!row) { return; } list.appendChild( row ); updateLinearTargetAvailability(); } ); } wrapper.addEventListener( 'click', function (event) { const removeButton = event.target.closest( '[data-remove-tick-effect]' ); if (!removeButton) { return; } const row = removeButton.closest( '[data-tick-effect-row]' ); if (row) { row.remove(); } } ); const navigationSelect = editor.querySelector( 'select[name="navigation_type"]' ); if (navigationSelect) { navigationSelect.addEventListener( 'change', updateLinearTargetAvailability ); } updateLinearTargetAvailability(); // ------------------------------------------------------------ // MOVE THE SECTION INTO ITS OWN BUILDER TAB // ------------------------------------------------------------ const form = wrapper.closest( 'form' ); if (!form) { return; } const tabBar = form.querySelector( '.builder-editor-tabs' ); if (!tabBar) { /* * Builder without the v10 tab layout: * leave the Tick Effects section inline. */ return; } if ( form.querySelector( '[data-builder-panel="tick_effects"]' ) ) { return; } const panel = document.createElement( 'div' ); panel.className = 'builder-tab-panel'; panel.dataset.builderPanel = 'tick_effects'; panel.hidden = true; panel.appendChild( wrapper ); const saveBar = form.querySelector( '.builder-save-bar' ); if (saveBar) { form.insertBefore( panel, saveBar ); } else { form.appendChild( panel ); } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'builder-editor-tab'; button.dataset.builderTab = 'tick_effects'; button.setAttribute( 'role', 'tab' ); button.setAttribute( 'aria-selected', 'false' ); button.textContent = 'Tick Effects'; tabBar.appendChild( button ); function activateTickEffectsTab() { tabBar .querySelectorAll( '[data-builder-tab]' ) .forEach( function (tabButton) { const active = tabButton === button; tabButton.classList.toggle( 'is-active', active ); tabButton.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); form .querySelectorAll( '[data-builder-panel]' ) .forEach( function (tabPanel) { tabPanel.hidden = tabPanel.dataset.builderPanel !== 'tick_effects'; } ); } button.addEventListener( 'click', activateTickEffectsTab ); /* * The original tab controller does not know about the panel added * afterward, so explicitly hide our panel whenever an original tab * is clicked. */ tabBar .querySelectorAll( '[data-builder-tab]:not([data-builder-tab="tick_effects"])' ) .forEach( function (originalButton) { originalButton.addEventListener( 'click', function () { panel.hidden = true; button.classList.remove( 'is-active' ); button.setAttribute( 'aria-selected', 'false' ); } ); } ); } initializeRegionTickEffects(); ================================================================================ FILE: _v18_2_backups/20260922-190142/includes/builder/tick-effects.php ================================================================================ $gameTraitId, 'name' => (string)( $gameTrait['name'] ?? $gameTraitId ), ]; } /* * Build current Region numeric Trait targets. */ $regionTickRegionTraits = []; foreach ($traits as $regionTrait) { if (!is_array($regionTrait)) { continue; } if ( isset($regionTrait['active']) && !$regionTrait['active'] ) { continue; } if ( ($regionTrait['type'] ?? '') !== 'number' ) { continue; } $regionTraitId = trim( (string)( $regionTrait['id'] ?? '' ) ); if ($regionTraitId === '') { continue; } $regionTickRegionTraits[] = [ 'id' => $regionTraitId, 'name' => (string)( $regionTrait['name'] ?? $regionTraitId ), ]; } function builderRegionTickEffectSelected( array $effect, string $scope, string $targetId ): bool { /* * Saved rows use explicit fields. * Failed POST rows use the encoded target selector. */ if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function builderRegionTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function builderRegionTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } ?>

Tick Effects

These effects are applied while this Region is the player's current Region. Runtime execution will be added separately.

$tickEffect ): ?>

================================================================================ FILE: _v18_backups/20260922-175135/assets/css/builder.css ================================================================================ /* |-------------------------------------------------------------------------- | Wayfarer Builder |-------------------------------------------------------------------------- | Master/detail authored-object workspace. | This stylesheet is intentionally presentation-only. */ :root { --wf-border: #d8dbe2; --wf-border-strong: #b8bdc7; --wf-surface: #ffffff; --wf-surface-soft: #f6f7f9; --wf-text: #20242a; --wf-muted: #667085; --wf-accent: #2f5ea8; --wf-accent-soft: #edf3fc; --wf-danger: #a61b2b; --wf-danger-soft: #fff4f5; --wf-radius: 8px; --wf-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); } .wayfarer-builder-page { margin: 0; padding: 24px; background: #f3f4f6; color: var(--wf-text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.45; } .wayfarer-builder-page > h1, .wayfarer-builder-page > p, .wayfarer-builder-page > nav, .wayfarer-builder-page > .builder-workspace, .wayfarer-builder-page > .builder-back-link { max-width: 1400px; margin-left: auto; margin-right: auto; } .wayfarer-builder-page > h1 { margin-bottom: 2px; } .wayfarer-builder-page > h1 + p { margin-top: 0; color: var(--wf-muted); } .wayfarer-builder-page > nav { margin-top: 18px; margin-bottom: 18px; padding: 10px 14px; background: var(--wf-surface); border: 1px solid var(--wf-border); border-radius: var(--wf-radius); box-shadow: var(--wf-shadow); } .wayfarer-builder-page > nav a, .wayfarer-builder-page > nav strong { display: inline-block; margin-right: 18px; } .wayfarer-builder-page > hr { display: none; } /* ------------------------------------------------------------------ MASTER / DETAIL ------------------------------------------------------------------ */ .builder-workspace { display: grid; grid-template-columns: minmax(260px, 340px) minmax(0, 1fr); gap: 18px; align-items: start; } .builder-collection-pane, .builder-editor-pane { background: var(--wf-surface); border: 1px solid var(--wf-border); border-radius: var(--wf-radius); box-shadow: var(--wf-shadow); } .builder-collection-pane { position: sticky; top: 18px; max-height: calc(100vh - 36px); overflow: hidden; } .builder-collection-toolbar { padding: 14px; border-bottom: 1px solid var(--wf-border); background: var(--wf-surface-soft); } .builder-new-object { display: inline-block; padding: 8px 11px; border: 1px solid var(--wf-border-strong); border-radius: 6px; background: var(--wf-surface); text-decoration: none; font-weight: 600; } .builder-collection-scroll { overflow: auto; max-height: calc(100vh - 92px); padding: 14px; } .builder-collection-scroll > h2 { margin-top: 0; margin-bottom: 12px; font-size: 1.15rem; } .builder-collection-scroll ul { margin: 0; padding: 0; list-style: none; } .builder-collection-scroll li + li { margin-top: 4px; } .builder-collection-scroll li a { display: block; padding: 8px 9px; border-radius: 5px; text-decoration: none; } .builder-collection-scroll li a:hover { background: var(--wf-accent-soft); } .builder-collection-scroll table { width: 100%; border-collapse: collapse; font-size: 0.88rem; } .builder-collection-scroll th, .builder-collection-scroll td { padding: 7px 6px; border: 1px solid var(--wf-border); text-align: left; vertical-align: top; } .builder-collection-scroll th button { border: 0; padding: 0; background: transparent; font: inherit; font-weight: 600; cursor: pointer; } .builder-editor-pane { min-width: 0; padding: 20px 24px 24px; } .builder-editor-pane > h2 { margin-top: 0; margin-bottom: 14px; } /* ------------------------------------------------------------------ TABBED OBJECT EDITOR ------------------------------------------------------------------ */ .builder-object-form { margin: 0; } .builder-editor-tabs { display: flex; gap: 4px; margin: 0 0 20px; padding: 0; border-bottom: 1px solid var(--wf-border); } .builder-editor-tab { position: relative; bottom: -1px; padding: 9px 14px; border: 1px solid transparent; border-bottom: 1px solid var(--wf-border); background: transparent; color: var(--wf-muted); font: inherit; font-weight: 600; cursor: pointer; } .builder-editor-tab:hover { color: var(--wf-text); background: var(--wf-surface-soft); } .builder-editor-tab.is-active { color: var(--wf-accent); background: var(--wf-surface); border-color: var(--wf-border) var(--wf-border) var(--wf-surface); border-radius: 6px 6px 0 0; } .builder-tab-panel[hidden] { display: none !important; } .builder-tab-panel > p:first-child { margin-top: 0; } /* ------------------------------------------------------------------ EXISTING FIELD CONTROLS ------------------------------------------------------------------ */ .builder-editor-pane label { font-weight: 600; } .builder-editor-pane small { color: var(--wf-muted); font-weight: 400; } .builder-editor-pane input[type="text"], .builder-editor-pane input[type="number"], .builder-editor-pane input[type="file"], .builder-editor-pane select, .builder-editor-pane textarea { box-sizing: border-box; max-width: 100%; margin-top: 4px; padding: 7px 8px; border: 1px solid var(--wf-border-strong); border-radius: 5px; background: var(--wf-surface); color: var(--wf-text); font: inherit; } .builder-editor-pane input[type="text"], .builder-editor-pane textarea { width: min(100%, 720px); } .builder-editor-pane textarea { resize: vertical; } .builder-editor-pane fieldset { margin: 0 0 16px; padding: 14px 16px; border: 1px solid var(--wf-border); border-radius: 7px; } .builder-editor-pane legend { padding: 0 6px; } .builder-editor-pane table { max-width: 100%; border-collapse: collapse; } .builder-editor-pane th, .builder-editor-pane td { padding: 7px; } /* System settings stay visually inside Basics. */ .builder-editor-pane [data-system-trait-settings] { margin: 16px 0; padding: 14px 16px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface-soft); } .builder-editor-pane [data-system-trait-settings] h3 { margin-top: 0; } /* ------------------------------------------------------------------ SAVE / DELETE ------------------------------------------------------------------ */ .builder-save-bar { display: flex; justify-content: flex-end; margin: 22px -24px -24px; padding: 14px 24px; border-top: 1px solid var(--wf-border); background: var(--wf-surface-soft); border-radius: 0 0 var(--wf-radius) var(--wf-radius); } .builder-save-bar button { padding: 9px 16px; border: 1px solid #214b86; border-radius: 6px; background: var(--wf-accent); color: #fff; font: inherit; font-weight: 600; cursor: pointer; } .builder-editor-pane [data-builder-delete-form] { margin-top: 28px; padding: 16px; border: 1px solid #e8b8be; border-radius: 7px; background: var(--wf-danger-soft); } .builder-editor-pane [data-builder-delete-form] h3 { margin-top: 0; color: var(--wf-danger); } .builder-editor-pane [data-builder-delete-form] button { padding: 8px 12px; border: 1px solid var(--wf-danger); border-radius: 5px; background: #fff; color: var(--wf-danger) !important; font: inherit; font-weight: 600; cursor: pointer; } .builder-back-link { margin-top: 18px; } /* ------------------------------------------------------------------ RESPONSIVE ------------------------------------------------------------------ */ @media (max-width: 900px) { .wayfarer-builder-page { padding: 14px; } .builder-workspace { grid-template-columns: 1fr; } .builder-collection-pane { position: static; max-height: none; } .builder-collection-scroll { max-height: 300px; } .builder-editor-pane { padding: 18px; } .builder-save-bar { margin-left: -18px; margin-right: -18px; margin-bottom: -18px; padding-left: 18px; padding-right: 18px; } } @media (max-width: 560px) { .builder-editor-tabs { overflow-x: auto; } .builder-editor-tab { white-space: nowrap; } } ================================================================================ FILE: _v18_backups/20260922-175135/assets/js/builder.js ================================================================================ const wayfarerConditionData = window.wayfarerConditionData || {}; function updateConditionalConfigFields() { document .querySelectorAll('[data-config-field]') .forEach(function (container) { const relationshipName = container.dataset.conditionRelationship; const requiredValue = container.dataset.conditionEquals; if (!relationshipName) { container.style.display = ''; return; } const relationshipSelect = document.querySelector( '[data-relationship="' + relationshipName + '"]' ); if (!relationshipSelect) { container.style.display = 'none'; return; } const selectedId = relationshipSelect.value; const actualValue = wayfarerConditionData[relationshipName]?.[selectedId]; container.style.display = actualValue === requiredValue ? '' : 'none'; }); } document .querySelectorAll('[data-relationship]') .forEach(function (select) { select.addEventListener( 'change', updateConditionalConfigFields ); }); // ================================================================ // SYSTEM TRAIT GROUPS // ================================================================ function updateSystemTraitGroups(container) { const selectorField = container.dataset.selectorField; if (!selectorField) { return; } const selector = document.querySelector( '[name="' + selectorField + '"]' ); if (!selector) { return; } const selectedGroup = selector.value; container .querySelectorAll('[data-system-trait-group]') .forEach(function (group) { const active = group.dataset.systemTraitGroup === selectedGroup; group.style.display = active ? '' : 'none'; group .querySelectorAll('input, select, textarea') .forEach(function (input) { input.disabled = !active; }); }); } document .querySelectorAll('[data-system-trait-settings]') .forEach(function (container) { const selectorField = container.dataset.selectorField; const selector = selectorField ? document.querySelector( '[name="' + selectorField + '"]' ) : null; if (selector) { selector.addEventListener( 'change', function () { updateSystemTraitGroups(container); } ); } updateSystemTraitGroups(container); }); // ================================================================ // COLLECTION SORTING // ================================================================ document .querySelectorAll('[data-sort-column]') .forEach(function (button) { button.addEventListener('click', function () { const table = button.closest('[data-collection-table]'); if (!table) { return; } const tbody = table.querySelector('tbody'); const columnName = button.dataset.sortColumn; const sortType = button.dataset.sortType || 'text'; const currentDirection = button.dataset.sortDirection || 'none'; const nextDirection = currentDirection === 'asc' ? 'desc' : 'asc'; table .querySelectorAll('[data-sort-column]') .forEach(function (otherButton) { otherButton.dataset.sortDirection = 'none'; }); button.dataset.sortDirection = nextDirection; const rows = Array.from(tbody.querySelectorAll('tr')); rows.sort(function (a, b) { const aCell = a.querySelector( '[data-column="' + columnName + '"]' ); const bCell = b.querySelector( '[data-column="' + columnName + '"]' ); const aValue = aCell?.dataset.sortValue ?? ''; const bValue = bCell?.dataset.sortValue ?? ''; let comparison = 0; if (sortType === 'number') { const aNumber = aValue === '' ? Number.POSITIVE_INFINITY : Number(aValue); const bNumber = bValue === '' ? Number.POSITIVE_INFINITY : Number(bValue); comparison = aNumber - bNumber; } else { comparison = aValue.localeCompare( bValue, undefined, { numeric: true, sensitivity: 'base' } ); } return nextDirection === 'asc' ? comparison : -comparison; }); rows.forEach(function (row) { tbody.appendChild(row); }); }); }); updateConditionalConfigFields(); // ================================================================ // MEDIA LIBRARY CONTROLS // ================================================================ function updateBuilderMediaSlot(slotContainer) { const checkedAction = slotContainer.querySelector( 'input[type="radio"][name^="media_action"]:checked' ); const action = checkedAction ? checkedAction.value : 'none'; const existingSelect = slotContainer.querySelector( '[data-media-existing]' ); if (existingSelect) { existingSelect.disabled = action !== 'existing'; } slotContainer .querySelectorAll( '[data-media-upload-fields] input' ) .forEach(function (input) { input.disabled = action !== 'upload'; }); } document .querySelectorAll('[data-media-slot]') .forEach(function (slotContainer) { slotContainer .querySelectorAll( 'input[type="radio"][name^="media_action"]' ) .forEach(function (radio) { radio.addEventListener( 'change', function () { updateBuilderMediaSlot( slotContainer ); } ); }); updateBuilderMediaSlot( slotContainer ); }); // ================================================================ // AUTHORED OBJECT EDITOR TABS // ================================================================ function initializeBuilderEditorTabs() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } /* * The editor component has one primary save form. The separate delete * form, when present, lives later in the editor pane and is deliberately * left outside the tab system. */ const form = Array .from(editor.children) .find(function (child) { return child.tagName === 'FORM'; }); if ( !form || form.dataset.builderTabbed === '1' ) { return; } form.dataset.builderTabbed = '1'; form.classList.add( 'builder-object-form' ); const originalChildren = Array.from( form.children ); const panels = { basics: document.createElement('div'), media: document.createElement('div'), traits: document.createElement('div') }; panels.basics.className = 'builder-tab-panel'; panels.media.className = 'builder-tab-panel'; panels.traits.className = 'builder-tab-panel'; panels.basics.dataset.builderPanel = 'basics'; panels.media.dataset.builderPanel = 'media'; panels.traits.dataset.builderPanel = 'traits'; let section = 'basics'; let saveContainer = null; let hasMedia = false; let hasTraits = false; originalChildren.forEach( function (child) { /* * Keep hidden record identifiers directly in the form. */ if ( child.tagName === 'INPUT' && child.type === 'hidden' ) { return; } /* * Save becomes a stable footer beneath the tab panels. */ const submitButton = child.querySelector ? child.querySelector( 'button[type="submit"], input[type="submit"]' ) : null; if (submitButton) { saveContainer = child; return; } /* * Ignore the separator immediately before a tab heading. * Other HR elements are harmless but unnecessary inside the * cleaned-up editor. */ if (child.tagName === 'HR') { child.remove(); return; } if (child.tagName === 'H3') { const heading = child.textContent .trim() .toLowerCase(); if (heading === 'media') { section = 'media'; hasMedia = true; child.remove(); return; } if (heading === 'traits') { section = 'traits'; hasTraits = true; child.remove(); return; } } /* * Config fields belong to Basics even though the old v9 Media * markup happens to precede them in the source template. */ if ( child.hasAttribute && child.hasAttribute( 'data-config-field' ) ) { section = 'basics'; } /* * A Media fieldset is definitive even if future markup changes * around the Media heading. */ if ( child.hasAttribute && child.hasAttribute( 'data-media-slot' ) ) { section = 'media'; hasMedia = true; } panels[section] .appendChild( child ); } ); /* * If Traits exist, anything moved after the Traits heading belongs there. * System traits do not use that heading and therefore stay in Basics. */ if ( panels.traits.children.length > 0 ) { hasTraits = true; } if ( panels.media.children.length > 0 ) { hasMedia = true; } const tabBar = document.createElement( 'div' ); tabBar.className = 'builder-editor-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const availablePanels = [ { key: 'basics', label: 'Basics', show: true }, { key: 'media', label: 'Media', show: hasMedia }, { key: 'traits', label: 'Traits', show: hasTraits } ].filter( function (item) { return item.show; } ); function activatePanel(panelKey) { tabBar .querySelectorAll( '[data-builder-tab]' ) .forEach( function (button) { const active = button.dataset.builderTab === panelKey; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); Object .entries(panels) .forEach( function (entry) { const key = entry[0]; const panel = entry[1]; panel.hidden = key !== panelKey; } ); } availablePanels.forEach( function (item) { const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'builder-editor-tab'; button.dataset.builderTab = item.key; button.setAttribute( 'role', 'tab' ); button.textContent = item.label; button.addEventListener( 'click', function () { activatePanel( item.key ); } ); tabBar.appendChild( button ); } ); /* * Insert the new structure after hidden inputs so all form fields remain * part of the same form and submit exactly as before. */ form.appendChild( tabBar ); availablePanels.forEach( function (item) { form.appendChild( panels[item.key] ); } ); if (saveContainer) { saveContainer.classList.add( 'builder-save-bar' ); form.appendChild( saveContainer ); } activatePanel( 'basics' ); } initializeBuilderEditorTabs(); ================================================================================ FILE: _v18_backups/20260922-175135/includes/builder/editor.php ================================================================================ $groupValues) { if (!is_array($groupValues)) { continue; } foreach ($groupValues as $traitName => $traitValue) { if (!isset($storedSystemTraitValues[$groupName][$traitName])) { $storedSystemTraitValues[$groupName][$traitName] = $traitValue; } } } } } $renderSystemTraits = function () use ( $record, $fields, $systemTraitsSelector, $systemTraitGroups, $storedSystemTraitValues, $storedSystemTraitMaxValues ) { if (!$systemTraitsSelector || empty($systemTraitGroups)) { return; } $selectedGroup = $record[$systemTraitsSelector] ?? ($fields[$systemTraitsSelector]['default'] ?? ''); ?>
$groupDefinition): ?>
>

$traitDefinition): ?>

$relationship): ?> $field): ?>

Media

Optional. Reuse an existing asset from this game's Media Library, upload a new asset, or leave the slot empty.

$mediaDefinition): ?>

Maximum upload: .

Current: ()



No existing assets have been uploaded to this game yet.

$field): ?>

Traits

Traits are additional values associated with this that can change during play.


Danger Zone

Permanently delete this . This cannot be undone.

================================================================================ FILE: _v18_backups/20260922-175135/includes/builder/save-record.php ================================================================================ 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { $configToSave['traits'] = $traits; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $values[] = $record[$fieldName]; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: _v18_backups/20260922-215858/play/assets/play.js ================================================================================ (() => { 'use strict'; const config = window.wayfarerTickRuntime; if (!config) { return; } const tickValue = document.getElementById( 'wayfarer-tick-value' ); const statusValue = document.getElementById( 'wayfarer-tick-status' ); const message = document.getElementById( 'wayfarer-tick-message' ); const onceButton = document.getElementById( 'wayfarer-tick-once' ); const autoButton = document.getElementById( 'wayfarer-tick-auto' ); const pauseButton = document.getElementById( 'wayfarer-tick-pause' ); if ( !tickValue || !statusValue || !message || !onceButton || !autoButton || !pauseButton ) { return; } let running = false; let requestInFlight = false; let timeoutId = null; function setMessage(text, isError = false) { message.textContent = text; message.style.color = isError ? 'red' : ''; } function renderControls() { statusValue.textContent = running ? 'Running' : 'Paused'; onceButton.disabled = running || requestInFlight; autoButton.disabled = running || requestInFlight; pauseButton.disabled = !running; } async function requestTick() { if (requestInFlight) { return null; } requestInFlight = true; renderControls(); try { const response = await fetch( config.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Tick endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Tick request failed.' ); } tickValue.textContent = String(data.tick); setMessage( 'Tick ' + data.tick + ' saved.' ); return data; } catch (error) { running = false; setMessage( error instanceof Error ? error.message : 'Tick request failed.', true ); return null; } finally { requestInFlight = false; renderControls(); } } async function runAutomaticTick() { if (!running) { return; } const result = await requestTick(); if ( !running || result === null ) { return; } timeoutId = window.setTimeout( runAutomaticTick, Number(config.intervalMs) || 1500 ); } onceButton.addEventListener( 'click', async () => { if (running) { return; } setMessage(''); await requestTick(); } ); autoButton.addEventListener( 'click', () => { if (running) { return; } running = true; setMessage( 'Automatic advancement started.' ); renderControls(); runAutomaticTick(); } ); pauseButton.addEventListener( 'click', () => { running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } setMessage( 'Automatic advancement paused.' ); renderControls(); } ); window.addEventListener( 'pagehide', () => { running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); } } ); renderControls(); })(); ================================================================================ FILE: _v18_backups/20260922-215858/play/includes/current-region.php ================================================================================ prepare( " SELECT * FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); return $row ?: null; } function wayfarerV16FirstActiveRegion( PDO $pdo, int $gameId ): ?array { $stmt = $pdo->prepare( " SELECT * FROM wf_regions WHERE game_id = ? AND status = 'active' ORDER BY id LIMIT 1 " ); $stmt->execute([ $gameId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); return $row ?: null; } function wayfarerV16EnsureCurrentRegion( PDO $pdo, int $gameUserId ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $gameUser = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$gameUser) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $gameId = (int)$gameUser['game_id']; $state = wayfarerRuntimeDecodeState( $gameUser['state'] ); if ( !isset($state['navigation']) || !is_array($state['navigation']) ) { $state['navigation'] = []; } $regionId = (int)( $state['navigation']['region_id'] ?? 0 ); $region = wayfarerV16LoadRegion( $pdo, $gameId, $regionId ); $changed = false; /* * Development rule: * first active Region is the starting Region. */ if (!$region) { $region = wayfarerV16FirstActiveRegion( $pdo, $gameId ); if ($region) { $regionId = (int)$region['id']; $state['navigation']['region_id'] = $regionId; if ( !array_key_exists( 'position', $state['navigation'] ) ) { $state['navigation']['position'] = 0; } $changed = true; } } $traits = []; if ($region) { $regionConfig = wayfarerV16DecodeConfig( $region['config'] ?? null ); $traits = wayfarerV16ActiveTraits( $regionConfig ); if ( !isset($state['regions']) || !is_array($state['regions']) ) { $state['regions'] = []; $changed = true; } $regionKey = (string)$regionId; if ( !isset($state['regions'][$regionKey]) || !is_array( $state['regions'][$regionKey] ) ) { $state['regions'][$regionKey] = []; $changed = true; } if ( !isset( $state['regions'][$regionKey]['traits'] ) || !is_array( $state['regions'][$regionKey]['traits'] ) ) { $state['regions'][$regionKey]['traits'] = []; $changed = true; } foreach ($traits as $trait) { $traitId = (string)$trait['_runtime_id']; if ( array_key_exists( $traitId, $state['regions'][$regionKey]['traits'] ) ) { continue; } $state['regions'][$regionKey]['traits'][$traitId] = wayfarerV16InitialTraitValue( $trait ); $changed = true; } } if ($changed) { $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ wayfarerRuntimeEncodeState( $state ), $gameUserId, ]); } $pdo->commit(); return [ 'region' => $region, 'traits' => $traits, 'state' => $state, 'region_id' => $regionId, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } $currentRegionError = ''; $currentRegionContext = null; try { $requestedGameUserId = isset($_GET['game_user_id']) ? (int)$_GET['game_user_id'] : null; $currentRegionGameUserId = wayfarerRuntimeResolveGameUserId( $pdo, $requestedGameUserId ); $currentRegionContext = wayfarerV16EnsureCurrentRegion( $pdo, $currentRegionGameUserId ); } catch (Throwable $exception) { $currentRegionError = $exception->getMessage(); } ?>

Current Region

No active Region is available for this game.

—

Region ID:   |   Position:

Region Traits

This Region has no active Traits.

Trait Current Value Type
================================================================================ FILE: _v18_backups/20260922-215858/play/includes/scene.php ================================================================================ prepare( " SELECT * FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $region = $stmt->fetch(PDO::FETCH_ASSOC); return $region ?: null; } function wayfarerV17LoadLocationAtPosition( PDO $pdo, int $regionId, $position ): ?array { $stmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $stmt->execute([ $regionId ]); $locations = $stmt->fetchAll(PDO::FETCH_ASSOC); foreach ($locations as $location) { $config = wayfarerV17DecodeConfig( $location['config'] ?? null ); if ( !array_key_exists('position', $config) || !is_numeric($config['position']) ) { continue; } if ( (float)$config['position'] === (float)$position ) { $location['_runtime_position'] = $config['position']; return $location; } } return null; } $sceneError = ''; $sceneGameUser = null; $sceneRegion = null; $sceneLocation = null; $scenePosition = 0; try { $requestedGameUserId = isset($_GET['game_user_id']) ? (int)$_GET['game_user_id'] : null; $sceneGameUserId = wayfarerRuntimeResolveGameUserId( $pdo, $requestedGameUserId ); $sceneGameUser = wayfarerRuntimeLoad( $pdo, $sceneGameUserId ); $sceneState = $sceneGameUser['state']; $sceneRegionId = (int)( $sceneState['navigation']['region_id'] ?? 0 ); $scenePosition = $sceneState['navigation']['position'] ?? 0; $sceneRegion = wayfarerV17LoadRegion( $pdo, (int)$sceneGameUser['game_id'], $sceneRegionId ); if ( $sceneRegion && ($sceneRegion['navigation_type'] ?? '') === 'linear' ) { $sceneLocation = wayfarerV17LoadLocationAtPosition( $pdo, (int)$sceneRegion['id'], $scenePosition ); } } catch (Throwable $exception) { $sceneError = $exception->getMessage(); } ?>

Current Scene

Current Scene

No current Region is available.

 —  Position

Position:


================================================================================ FILE: _v18_backups/20260922-215858/play/index.php ================================================================================ prepare(" SELECT g.id AS game_id, g.name AS game_name, g.slug AS game_slug, g.config AS game_config, gu.id AS game_user_id, gu.role AS game_user_role, gu.state, u.username, u.display_name FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id INNER JOIN wf_users u ON u.id = gu.user_id WHERE gu.game_id = ? AND gu.user_id = ? LIMIT 1 "); $stmt->execute([ $gameId, $userId ]); $gameUser = $stmt->fetch(); if (!$gameUser) { die('Game user not found.'); } // ================================================================ // RUNTIME STATE // ================================================================ $state = null; if (!empty($gameUser['state'])) { $decodedState = json_decode( $gameUser['state'], true ); if (is_array($decodedState)) { $state = $decodedState; } } // ================================================================ // INITIALIZE GAME // ================================================================ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'initialize' && $state === null ) { // ------------------------------------------------------------ // LOAD AUTHORED GAME CONFIG // ------------------------------------------------------------ $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } // ------------------------------------------------------------ // INITIALIZE GAME TRAITS // ------------------------------------------------------------ $gameTraits = []; foreach ($gameConfig['traits'] ?? [] as $trait) { if ( empty($trait['id']) || !array_key_exists('initial', $trait) ) { continue; } $gameTraits[$trait['id']] = $trait['initial']; } // ------------------------------------------------------------ // BUILD INITIAL RUNTIME STATE // ------------------------------------------------------------ $state = [ 'game' => [ 'traits' => $gameTraits ] ]; // ------------------------------------------------------------ // SAVE RUNTIME STATE // ------------------------------------------------------------ $stateJson = json_encode( $state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $stmt = $pdo->prepare(" UPDATE wf_game_users SET state = ? WHERE id = ? "); $stmt->execute([ $stateJson, $gameUser['game_user_id'] ]); // Reload so refresh cannot repeat the POST. header('Location: index.php'); exit; } // ================================================================ // AUTHOR MODE: GAME TRAIT DEFINITIONS // ================================================================ $gameTraitDefinitions = []; $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } foreach ($gameConfig['traits'] ?? [] as $trait) { if (empty($trait['id'])) { continue; } $gameTraitDefinitions[$trait['id']] = $trait; } ?> <?= htmlspecialchars($gameUser['game_name']) ?>

Player:


Game Not Initialized

This game-user record exists, but no runtime state has been created yet.

Game State

Runtime state has been initialized.


Author Mode

Open Wayfarer Builder

Game Traits

No game traits are currently stored.

$value): ?>
Trait Current Value Type
Unknown Trait

Runtime Debug

================================================================================ FILE: _v19_backups/20260922-195725/admin/builder.php ================================================================================ prepare(" SELECT id, name FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; // ================================================================ // SAVE / DELETE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { /* * Media Library associations are applied only after the authored * object has saved successfully and therefore has a stable ID. */ $savedRecordId = (int)$saveResult['id']; $mediaResult = applyBuilderMediaSelections( $pdo, $gameId, $definition['table'], $savedRecordId, $_POST, $_FILES ); if (!$mediaResult['saved']) { /* * The authored object itself has already saved. Keep the * editor on that record and report the media failure * accurately rather than pretending the object save failed. */ $id = $savedRecordId; $record['id'] = $savedRecordId; $error = 'Record saved, but media was not changed: ' . $mediaResult['error']; } else { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder

================================================================================ FILE: _v19_backups/20260922-220945/play/index.php ================================================================================ prepare(" SELECT g.id AS game_id, g.name AS game_name, g.slug AS game_slug, g.config AS game_config, gu.id AS game_user_id, gu.role AS game_user_role, gu.state, u.username, u.display_name FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id INNER JOIN wf_users u ON u.id = gu.user_id WHERE gu.game_id = ? AND gu.user_id = ? LIMIT 1 "); $stmt->execute([ $gameId, $userId ]); $gameUser = $stmt->fetch(); if (!$gameUser) { die('Game user not found.'); } // ================================================================ // RUNTIME STATE // ================================================================ $state = null; if (!empty($gameUser['state'])) { $decodedState = json_decode( $gameUser['state'], true ); if (is_array($decodedState)) { $state = $decodedState; } } // ================================================================ // INITIALIZE GAME // ================================================================ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'initialize' && $state === null ) { // ------------------------------------------------------------ // LOAD AUTHORED GAME CONFIG // ------------------------------------------------------------ $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } // ------------------------------------------------------------ // INITIALIZE GAME TRAITS // ------------------------------------------------------------ $gameTraits = []; foreach ($gameConfig['traits'] ?? [] as $trait) { if ( empty($trait['id']) || !array_key_exists('initial', $trait) ) { continue; } $gameTraits[$trait['id']] = $trait['initial']; } // ------------------------------------------------------------ // BUILD INITIAL RUNTIME STATE // ------------------------------------------------------------ $state = [ 'game' => [ 'traits' => $gameTraits ] ]; // ------------------------------------------------------------ // SAVE RUNTIME STATE // ------------------------------------------------------------ $stateJson = json_encode( $state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $stmt = $pdo->prepare(" UPDATE wf_game_users SET state = ? WHERE id = ? "); $stmt->execute([ $stateJson, $gameUser['game_user_id'] ]); // Reload so refresh cannot repeat the POST. header('Location: index.php'); exit; } // ================================================================ // AUTHOR MODE: GAME TRAIT DEFINITIONS // ================================================================ $gameTraitDefinitions = []; $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } foreach ($gameConfig['traits'] ?? [] as $trait) { if (empty($trait['id'])) { continue; } $gameTraitDefinitions[$trait['id']] = $trait; } ?> <?= htmlspecialchars($gameUser['game_name']) ?>

Player:


Game Not Initialized

This game-user record exists, but no runtime state has been created yet.

Game State

Runtime state has been initialized.


Author Mode

Open Wayfarer Builder

Game Traits

No game traits are currently stored.

$value): ?>
Trait Current Value Type
Unknown Trait

Runtime Debug

================================================================================ FILE: _v20_backups/20260922-200114/admin/builder.php ================================================================================ prepare(" SELECT id, name FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; // ================================================================ // SAVE / DELETE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { /* * Media Library associations are applied only after the authored * object has saved successfully and therefore has a stable ID. */ $savedRecordId = (int)$saveResult['id']; $mediaResult = applyBuilderMediaSelections( $pdo, $gameId, $definition['table'], $savedRecordId, $_POST, $_FILES ); if (!$mediaResult['saved']) { /* * The authored object itself has already saved. Keep the * editor on that record and report the media failure * accurately rather than pretending the object save failed. */ $id = $savedRecordId; $record['id'] = $savedRecordId; $error = 'Record saved, but media was not changed: ' . $mediaResult['error']; } else { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) . '&id=' . (int)$saveResult['id'] ); exit; } } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder

================================================================================ FILE: _v20_backups/20260922-200114/assets/js/builder.js ================================================================================ const wayfarerConditionData = window.wayfarerConditionData || {}; function updateConditionalConfigFields() { document .querySelectorAll('[data-config-field]') .forEach(function (container) { const relationshipName = container.dataset.conditionRelationship; const requiredValue = container.dataset.conditionEquals; if (!relationshipName) { container.style.display = ''; return; } const relationshipSelect = document.querySelector( '[data-relationship="' + relationshipName + '"]' ); if (!relationshipSelect) { container.style.display = 'none'; return; } const selectedId = relationshipSelect.value; const actualValue = wayfarerConditionData[relationshipName]?.[selectedId]; container.style.display = actualValue === requiredValue ? '' : 'none'; }); } document .querySelectorAll('[data-relationship]') .forEach(function (select) { select.addEventListener( 'change', updateConditionalConfigFields ); }); // ================================================================ // SYSTEM TRAIT GROUPS // ================================================================ function updateSystemTraitGroups(container) { const selectorField = container.dataset.selectorField; if (!selectorField) { return; } const selector = document.querySelector( '[name="' + selectorField + '"]' ); if (!selector) { return; } const selectedGroup = selector.value; container .querySelectorAll('[data-system-trait-group]') .forEach(function (group) { const active = group.dataset.systemTraitGroup === selectedGroup; group.style.display = active ? '' : 'none'; group .querySelectorAll('input, select, textarea') .forEach(function (input) { input.disabled = !active; }); }); } document .querySelectorAll('[data-system-trait-settings]') .forEach(function (container) { const selectorField = container.dataset.selectorField; const selector = selectorField ? document.querySelector( '[name="' + selectorField + '"]' ) : null; if (selector) { selector.addEventListener( 'change', function () { updateSystemTraitGroups(container); } ); } updateSystemTraitGroups(container); }); // ================================================================ // COLLECTION SORTING // ================================================================ document .querySelectorAll('[data-sort-column]') .forEach(function (button) { button.addEventListener('click', function () { const table = button.closest('[data-collection-table]'); if (!table) { return; } const tbody = table.querySelector('tbody'); const columnName = button.dataset.sortColumn; const sortType = button.dataset.sortType || 'text'; const currentDirection = button.dataset.sortDirection || 'none'; const nextDirection = currentDirection === 'asc' ? 'desc' : 'asc'; table .querySelectorAll('[data-sort-column]') .forEach(function (otherButton) { otherButton.dataset.sortDirection = 'none'; }); button.dataset.sortDirection = nextDirection; const rows = Array.from(tbody.querySelectorAll('tr')); rows.sort(function (a, b) { const aCell = a.querySelector( '[data-column="' + columnName + '"]' ); const bCell = b.querySelector( '[data-column="' + columnName + '"]' ); const aValue = aCell?.dataset.sortValue ?? ''; const bValue = bCell?.dataset.sortValue ?? ''; let comparison = 0; if (sortType === 'number') { const aNumber = aValue === '' ? Number.POSITIVE_INFINITY : Number(aValue); const bNumber = bValue === '' ? Number.POSITIVE_INFINITY : Number(bValue); comparison = aNumber - bNumber; } else { comparison = aValue.localeCompare( bValue, undefined, { numeric: true, sensitivity: 'base' } ); } return nextDirection === 'asc' ? comparison : -comparison; }); rows.forEach(function (row) { tbody.appendChild(row); }); }); }); updateConditionalConfigFields(); // ================================================================ // MEDIA LIBRARY CONTROLS // ================================================================ function updateBuilderMediaSlot(slotContainer) { const checkedAction = slotContainer.querySelector( 'input[type="radio"][name^="media_action"]:checked' ); const action = checkedAction ? checkedAction.value : 'none'; const existingSelect = slotContainer.querySelector( '[data-media-existing]' ); if (existingSelect) { existingSelect.disabled = action !== 'existing'; } slotContainer .querySelectorAll( '[data-media-upload-fields] input' ) .forEach(function (input) { input.disabled = action !== 'upload'; }); } document .querySelectorAll('[data-media-slot]') .forEach(function (slotContainer) { slotContainer .querySelectorAll( 'input[type="radio"][name^="media_action"]' ) .forEach(function (radio) { radio.addEventListener( 'change', function () { updateBuilderMediaSlot( slotContainer ); } ); }); updateBuilderMediaSlot( slotContainer ); }); // ================================================================ // AUTHORED OBJECT EDITOR TABS // ================================================================ function initializeBuilderEditorTabs() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } /* * The editor component has one primary save form. The separate delete * form, when present, lives later in the editor pane and is deliberately * left outside the tab system. */ const form = Array .from(editor.children) .find(function (child) { return child.tagName === 'FORM'; }); if ( !form || form.dataset.builderTabbed === '1' ) { return; } form.dataset.builderTabbed = '1'; form.classList.add( 'builder-object-form' ); const originalChildren = Array.from( form.children ); const panels = { basics: document.createElement('div'), media: document.createElement('div'), traits: document.createElement('div'), tick_effects: document.createElement('div') }; panels.basics.className = 'builder-tab-panel'; panels.media.className = 'builder-tab-panel'; panels.traits.className = 'builder-tab-panel'; panels.basics.dataset.builderPanel = 'basics'; panels.media.dataset.builderPanel = 'media'; panels.traits.dataset.builderPanel = 'traits'; panels.tick_effects.className = 'builder-tab-panel'; panels.tick_effects.dataset.builderPanel = 'tick_effects'; let section = 'basics'; let saveContainer = null; let hasMedia = false; let hasTraits = false; let hasTickEffects = false; originalChildren.forEach( function (child) { /* * Keep hidden record identifiers directly in the form. */ if ( child.tagName === 'INPUT' && child.type === 'hidden' ) { return; } /* * Save becomes a stable footer beneath the tab panels. */ const submitButton = child.querySelector ? child.querySelector( 'button[type="submit"], input[type="submit"]' ) : null; if (submitButton) { saveContainer = child; return; } /* * Ignore the separator immediately before a tab heading. * Other HR elements are harmless but unnecessary inside the * cleaned-up editor. */ if (child.tagName === 'HR') { child.remove(); return; } if (child.tagName === 'H3') { const heading = child.textContent .trim() .toLowerCase(); if (heading === 'media') { section = 'media'; hasMedia = true; child.remove(); return; } if (heading === 'traits') { section = 'traits'; hasTraits = true; child.remove(); return; } } /* * Region Tick Effects have their own native panel. */ if ( child.hasAttribute && child.hasAttribute( 'data-builder-tick-effects' ) ) { section = 'tick_effects'; hasTickEffects = true; } /* * Config fields belong to Basics even though the old v9 Media * markup happens to precede them in the source template. */ if ( child.hasAttribute && child.hasAttribute( 'data-config-field' ) ) { section = 'basics'; } /* * A Media fieldset is definitive even if future markup changes * around the Media heading. */ if ( child.hasAttribute && child.hasAttribute( 'data-media-slot' ) ) { section = 'media'; hasMedia = true; } panels[section] .appendChild( child ); } ); /* * If Traits exist, anything moved after the Traits heading belongs there. * System traits do not use that heading and therefore stay in Basics. */ if ( panels.traits.children.length > 0 ) { hasTraits = true; } if ( panels.media.children.length > 0 ) { hasMedia = true; } if ( panels.tick_effects.children.length > 0 ) { hasTickEffects = true; } const tabBar = document.createElement( 'div' ); tabBar.className = 'builder-editor-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const availablePanels = [ { key: 'basics', label: 'Basics', show: true }, { key: 'media', label: 'Media', show: hasMedia }, { key: 'traits', label: 'Traits', show: hasTraits }, { key: 'tick_effects', label: 'Tick Effects', show: hasTickEffects } ].filter( function (item) { return item.show; } ); function activatePanel(panelKey) { tabBar .querySelectorAll( '[data-builder-tab]' ) .forEach( function (button) { const active = button.dataset.builderTab === panelKey; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); Object .entries(panels) .forEach( function (entry) { const key = entry[0]; const panel = entry[1]; panel.hidden = key !== panelKey; } ); } availablePanels.forEach( function (item) { const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'builder-editor-tab'; button.dataset.builderTab = item.key; button.setAttribute( 'role', 'tab' ); button.textContent = item.label; button.addEventListener( 'click', function () { activatePanel( item.key ); } ); tabBar.appendChild( button ); } ); /* * Insert the new structure after hidden inputs so all form fields remain * part of the same form and submit exactly as before. */ form.appendChild( tabBar ); availablePanels.forEach( function (item) { form.appendChild( panels[item.key] ); } ); if (saveContainer) { saveContainer.classList.add( 'builder-save-bar' ); form.appendChild( saveContainer ); } activatePanel( 'basics' ); } initializeBuilderEditorTabs(); // ================================================================ // REGION TICK EFFECTS TAB / CONTROLS v18 // ================================================================ function initializeRegionTickEffects() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } const wrapper = editor.querySelector( '[data-builder-tick-effects]' ); if (!wrapper) { return; } if ( wrapper.dataset.tickEffectsReady === '1' ) { return; } wrapper.dataset.tickEffectsReady = '1'; // ------------------------------------------------------------ // ROW ADD / REMOVE // ------------------------------------------------------------ const list = wrapper.querySelector( '[data-tick-effects-list]' ); const addButton = wrapper.querySelector( '[data-add-tick-effect]' ); const template = wrapper.querySelector( '[data-tick-effect-template]' ); let nextIndex = 0; wrapper .querySelectorAll( '[data-tick-effect-row]' ) .forEach( function (row) { const index = Number( row.dataset.tickIndex ); if ( Number.isFinite(index) && index >= nextIndex ) { nextIndex = index + 1; } } ); function updateLinearTargetAvailability() { const navigationSelect = editor.querySelector( 'select[name="navigation_type"]' ); const navigationType = navigationSelect ? navigationSelect.value : wrapper.dataset.navigationType; const linear = navigationType === 'linear'; wrapper .querySelectorAll( '[data-linear-navigation-target]' ) .forEach( function (option) { option.disabled = !linear; } ); if (!linear) { wrapper .querySelectorAll( '[data-tick-effect-target]' ) .forEach( function (select) { if ( select.value === 'navigation:position' ) { select.value = ''; } } ); } } if ( addButton && list && template ) { addButton.addEventListener( 'click', function () { const html = template.innerHTML.replace( /__INDEX__/g, String( nextIndex++ ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const row = temporary.firstElementChild; if (!row) { return; } list.appendChild( row ); updateLinearTargetAvailability(); } ); } wrapper.addEventListener( 'click', function (event) { const removeButton = event.target.closest( '[data-remove-tick-effect]' ); if (!removeButton) { return; } const row = removeButton.closest( '[data-tick-effect-row]' ); if (row) { row.remove(); } } ); const navigationSelect = editor.querySelector( 'select[name="navigation_type"]' ); if (navigationSelect) { navigationSelect.addEventListener( 'change', updateLinearTargetAvailability ); } updateLinearTargetAvailability(); // ------------------------------------------------------------ // MOVE THE SECTION INTO ITS OWN BUILDER TAB // ------------------------------------------------------------ const form = wrapper.closest( 'form' ); if (!form) { return; } const tabBar = form.querySelector( '.builder-editor-tabs' ); if (!tabBar) { /* * Builder without the v10 tab layout: * leave the Tick Effects section inline. */ return; } if ( form.querySelector( '[data-builder-panel="tick_effects"]' ) ) { return; } const panel = document.createElement( 'div' ); panel.className = 'builder-tab-panel'; panel.dataset.builderPanel = 'tick_effects'; panel.hidden = true; panel.appendChild( wrapper ); const saveBar = form.querySelector( '.builder-save-bar' ); if (saveBar) { form.insertBefore( panel, saveBar ); } else { form.appendChild( panel ); } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'builder-editor-tab'; button.dataset.builderTab = 'tick_effects'; button.setAttribute( 'role', 'tab' ); button.setAttribute( 'aria-selected', 'false' ); button.textContent = 'Tick Effects'; tabBar.appendChild( button ); function activateTickEffectsTab() { tabBar .querySelectorAll( '[data-builder-tab]' ) .forEach( function (tabButton) { const active = tabButton === button; tabButton.classList.toggle( 'is-active', active ); tabButton.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); form .querySelectorAll( '[data-builder-panel]' ) .forEach( function (tabPanel) { tabPanel.hidden = tabPanel.dataset.builderPanel !== 'tick_effects'; } ); } button.addEventListener( 'click', activateTickEffectsTab ); /* * The original tab controller does not know about the panel added * afterward, so explicitly hide our panel whenever an original tab * is clicked. */ tabBar .querySelectorAll( '[data-builder-tab]:not([data-builder-tab="tick_effects"])' ) .forEach( function (originalButton) { originalButton.addEventListener( 'click', function () { panel.hidden = true; button.classList.remove( 'is-active' ); button.setAttribute( 'aria-selected', 'false' ); } ); } ); } initializeRegionTickEffects(); ================================================================================ FILE: _v20_backups/20260922-221640/play/assets/play.js ================================================================================ (() => { 'use strict'; const config = window.wayfarerTickRuntime; if (!config) { return; } const tickValue = document.getElementById( 'wayfarer-tick-value' ); const statusValue = document.getElementById( 'wayfarer-tick-status' ); const message = document.getElementById( 'wayfarer-tick-message' ); const onceButton = document.getElementById( 'wayfarer-tick-once' ); const autoButton = document.getElementById( 'wayfarer-tick-auto' ); const pauseButton = document.getElementById( 'wayfarer-tick-pause' ); if ( !tickValue || !statusValue || !message || !onceButton || !autoButton || !pauseButton ) { return; } let running = false; let requestInFlight = false; let timeoutId = null; function setMessage(text, isError = false) { message.textContent = text; message.style.color = isError ? 'red' : ''; } function renderControls() { statusValue.textContent = running ? 'Running' : 'Paused'; onceButton.disabled = running || requestInFlight; autoButton.disabled = running || requestInFlight; pauseButton.disabled = !running; } function displayRuntimeValue(value) { if (typeof value === 'boolean') { return value ? 'True' : 'False'; } if ( value === null || value === undefined ) { return 'null'; } if (typeof value === 'object') { return JSON.stringify( value ); } return String( value ); } function renderRuntimeState(state) { if ( !state || typeof state !== 'object' ) { return; } /* * Navigation position. */ if ( state.navigation && state.navigation.position !== undefined ) { const position = String( state.navigation.position ); const playerPosition = document.getElementById( 'wayfarer-player-position' ); const authorPosition = document.getElementById( 'wayfarer-author-position' ); if (playerPosition) { playerPosition.textContent = position; } if (authorPosition) { authorPosition.textContent = position; } } /* * Game Trait values. */ const gameTraits = state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : {}; document .querySelectorAll( '[data-wayfarer-game-trait-value]' ) .forEach( function (cell) { const traitId = cell.dataset .wayfarerGameTraitValue; if ( Object.prototype .hasOwnProperty .call( gameTraits, traitId ) ) { cell.textContent = displayRuntimeValue( gameTraits[traitId] ); } } ); /* * Current Region Trait values. */ document .querySelectorAll( '[data-wayfarer-region-trait-value]' ) .forEach( function (cell) { const regionId = cell.dataset .wayfarerRegionId; const traitId = cell.dataset .wayfarerRegionTraitValue; const traits = state.regions && state.regions[regionId] && state.regions[regionId].traits && typeof state.regions[regionId].traits === 'object' ? state.regions[regionId].traits : null; if ( traits && Object.prototype .hasOwnProperty .call( traits, traitId ) ) { cell.textContent = displayRuntimeValue( traits[traitId] ); } } ); /* * Complete runtime debug state. */ const debug = document.getElementById( 'wayfarer-runtime-debug' ); if (debug) { debug.textContent = JSON.stringify( state, null, 2 ); } } async function requestTick() { if (requestInFlight) { return null; } requestInFlight = true; renderControls(); try { const response = await fetch( config.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Tick endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Tick request failed.' ); } tickValue.textContent = String(data.tick); renderRuntimeState( data.state ); setMessage( 'Tick ' + data.tick + ' saved.' ); return data; } catch (error) { running = false; setMessage( error instanceof Error ? error.message : 'Tick request failed.', true ); return null; } finally { requestInFlight = false; renderControls(); } } async function runAutomaticTick() { if (!running) { return; } const result = await requestTick(); if ( !running || result === null ) { return; } timeoutId = window.setTimeout( runAutomaticTick, Number(config.intervalMs) || 1500 ); } onceButton.addEventListener( 'click', async () => { if (running) { return; } setMessage(''); await requestTick(); } ); autoButton.addEventListener( 'click', () => { if (running) { return; } running = true; setMessage( 'Automatic advancement started.' ); renderControls(); runAutomaticTick(); } ); pauseButton.addEventListener( 'click', () => { running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } setMessage( 'Automatic advancement paused.' ); renderControls(); } ); window.addEventListener( 'pagehide', () => { running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); } } ); renderControls(); })(); ================================================================================ FILE: _v20_backups/20260922-221640/play/index.php ================================================================================ prepare(" SELECT g.id AS game_id, g.name AS game_name, g.slug AS game_slug, g.config AS game_config, gu.id AS game_user_id, gu.role AS game_user_role, gu.state, u.username, u.display_name FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id INNER JOIN wf_users u ON u.id = gu.user_id WHERE gu.game_id = ? AND gu.user_id = ? LIMIT 1 "); $stmt->execute([ $gameId, $userId ]); $gameUser = $stmt->fetch(); if (!$gameUser) { die('Game user not found.'); } // ================================================================ // RUNTIME STATE // ================================================================ $state = null; if (!empty($gameUser['state'])) { $decodedState = json_decode( $gameUser['state'], true ); if (is_array($decodedState)) { $state = $decodedState; } } // ================================================================ // INITIALIZE GAME // ================================================================ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'initialize' && $state === null ) { // ------------------------------------------------------------ // LOAD AUTHORED GAME CONFIG // ------------------------------------------------------------ $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } // ------------------------------------------------------------ // INITIALIZE GAME TRAITS // ------------------------------------------------------------ $gameTraits = []; foreach ($gameConfig['traits'] ?? [] as $trait) { if ( empty($trait['id']) || !array_key_exists('initial', $trait) ) { continue; } $gameTraits[$trait['id']] = $trait['initial']; } // ------------------------------------------------------------ // BUILD INITIAL RUNTIME STATE // ------------------------------------------------------------ $state = [ 'game' => [ 'traits' => $gameTraits ] ]; // ------------------------------------------------------------ // SAVE RUNTIME STATE // ------------------------------------------------------------ $stateJson = json_encode( $state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $stmt = $pdo->prepare(" UPDATE wf_game_users SET state = ? WHERE id = ? "); $stmt->execute([ $stateJson, $gameUser['game_user_id'] ]); // Reload so refresh cannot repeat the POST. header('Location: index.php'); exit; } // ================================================================ // AUTHOR MODE: GAME TRAIT DEFINITIONS // ================================================================ $gameTraitDefinitions = []; $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } foreach ($gameConfig['traits'] ?? [] as $trait) { if (empty($trait['id'])) { continue; } $gameTraitDefinitions[$trait['id']] = $trait; } ?> <?= htmlspecialchars($gameUser['game_name']) ?>

Player:


Game Not Initialized

This game-user record exists, but no runtime state has been created yet.

Game State

Runtime state has been initialized.


Author Mode

Open Wayfarer Builder

Game Traits

No game traits are currently stored.

$value): ?>
Trait Current Value Type
Unknown Trait
Runtime Debug
================================================================================ FILE: _v21_1_backups/20260922-202431/admin/builder.php ================================================================================ prepare(" SELECT id, name FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; // ================================================================ // SAVE / DELETE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { /* * Media Library associations are applied only after the authored * object has saved successfully and therefore has a stable ID. */ $savedRecordId = (int)$saveResult['id']; $mediaResult = applyBuilderMediaSelections( $pdo, $gameId, $definition['table'], $savedRecordId, $_POST, $_FILES ); if (!$mediaResult['saved']) { /* * The authored object itself has already saved. Keep the * editor on that record and report the media failure * accurately rather than pretending the object save failed. */ $id = $savedRecordId; $record['id'] = $savedRecordId; $error = 'Record saved, but media was not changed: ' . $mediaResult['error']; } else { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) . '&id=' . (int)$saveResult['id'] . '&tab=' . rawurlencode( (string)($_POST['active_tab'] ?? 'basics') ) ); exit; } } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder

================================================================================ FILE: _v21_1_backups/20260922-202431/admin/games/edit.php ================================================================================ null, 'name' => '', 'slug' => '', 'description' => '', 'instructions' => '', 'player_slots' => 0, 'config' => null, 'status' => 'draft' ]; $traits = []; $config = []; $error = ''; // ================================================================ // LOAD EXISTING GAME // ================================================================ if (isset($_GET['id'])) { $stmt = $pdo->prepare(" SELECT * FROM wf_games WHERE id = ? "); $stmt->execute([(int)$_GET['id']]); $existingGame = $stmt->fetch(); if (!$existingGame) { die('Game not found.'); } $game = array_merge($game, $existingGame); if (!empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } if ( isset($config['traits']) && is_array($config['traits']) ) { $traits = $config['traits']; } } // ================================================================ // SAVE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $game['id'] = !empty($_POST['id']) ? (int)$_POST['id'] : null; $game['name'] = trim($_POST['name'] ?? ''); $game['slug'] = trim($_POST['slug'] ?? ''); $game['description'] = trim($_POST['description'] ?? ''); $game['instructions'] = trim($_POST['instructions'] ?? ''); $game['player_slots'] = max(0, (int)($_POST['player_slots'] ?? 0)); $game['status'] = $_POST['status'] ?? 'draft'; // ------------------------------------------------------------ // BUILD TRAITS // ------------------------------------------------------------ $traitResult = buildTraitsFromPost( $_POST['traits'] ?? [] ); $traits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } // ------------------------------------------------------------ // VALIDATE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['name'] === '') { $error = 'Game name is required.'; } elseif ($game['slug'] === '') { $error = 'Slug is required.'; } } // ------------------------------------------------------------ // BUILD CONFIG JSON // ------------------------------------------------------------ if ($error === '') { /* * Preserve any other configuration already stored for the game. * Traits own only the "traits" section of config. */ if (empty($config) && !empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } $startingRegionId = !empty($_POST['starting_region_id']) ? (int)$_POST['starting_region_id'] : null; if ($startingRegionId !== null) { $config['starting_region_id'] = $startingRegionId; } else { unset($config['starting_region_id']); } $config['traits'] = $traits; $game['config'] = json_encode( $config, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ($game['config'] === false) { $error = 'Unable to encode game configuration.'; } } // ------------------------------------------------------------ // WRITE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['id']) { $stmt = $pdo->prepare(" UPDATE wf_games SET name = ?, slug = ?, description = ?, instructions = ?, player_slots = ?, config = ?, status = ? WHERE id = ? "); $stmt->execute([ $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'], $game['id'] ]); } else { $uuid = bin2hex(random_bytes(16)); $stmt = $pdo->prepare(" INSERT INTO wf_games ( uuid, name, slug, description, instructions, player_slots, config, status ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $uuid, $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'] ]); $game['id'] = (int)$pdo->lastInsertId(); } header('Location: index.php'); exit; } } // ================================================================ // LOAD REGIONS FOR STARTING REGION // ================================================================ $regions = []; if (!empty($game['id'])) { $regionStmt = $pdo->prepare(" SELECT id, name FROM wf_regions WHERE game_id = ? ORDER BY name "); $regionStmt->execute([ $game['id'] ]); $regions = $regionStmt->fetchAll(); } ?> <?= $game['id'] ? 'Edit Game' : 'New Game' ?> - Wayfarer


Example: stormgate


0 = no player-entered names; 1 = one; 5 = five, etc.



Traits

Traits are game-defined values that can change during play.



================================================================================ FILE: _v21_3_backups/20260922-202852/admin/builder.php ================================================================================ prepare(" SELECT id, name FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; // ================================================================ // SAVE / DELETE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { /* * Media Library associations are applied only after the authored * object has saved successfully and therefore has a stable ID. */ $savedRecordId = (int)$saveResult['id']; $mediaResult = applyBuilderMediaSelections( $pdo, $gameId, $definition['table'], $savedRecordId, $_POST, $_FILES ); if (!$mediaResult['saved']) { /* * The authored object itself has already saved. Keep the * editor on that record and report the media failure * accurately rather than pretending the object save failed. */ $id = $savedRecordId; $record['id'] = $savedRecordId; $error = 'Record saved, but media was not changed: ' . $mediaResult['error']; } else { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) . '&id=' . (int)$saveResult['id'] . '&tab=' . rawurlencode( (string)($_POST['active_tab'] ?? 'basics') ) ); exit; } } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder

================================================================================ FILE: _v4_backups/20260922-144120/assets/js/builder.js ================================================================================ Untitled Document ================================================================================ FILE: _v4_backups/20260922-144120/config/builder-types.php ================================================================================ [ 'label' => 'Regions', 'singular' => 'Region', 'table' => 'wf_regions', 'list_field' => 'name', 'traits' => true, 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'navigation_type' => [ 'label' => 'Navigation Type', 'type' => 'select', 'required' => true, 'options' => [ 'linear' => 'Linear', 'grid' => 'Grid', 'freeform' => 'Freeform' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ] ], // ================================================================ // LOCATIONS // ================================================================ 'locations' => [ 'label' => 'Locations', 'singular' => 'Location', 'table' => 'wf_locations', 'list_field' => 'name', 'traits' => true, /* * Defines the columns shown in the collection table. */ 'list_columns' => [ 'name' => [ 'label' => 'Name', 'source' => 'field', 'field' => 'name', 'sortable' => true ], 'region' => [ 'label' => 'Region', 'source' => 'relationship', 'relationship' => 'region_id', 'sortable' => true ], 'position' => [ 'label' => 'Position', 'source' => 'config', 'field' => 'position', 'type' => 'number', 'sortable' => true ] ], /* * Locations belong to regions rather than directly to games. */ 'scope' => [ 'type' => 'region', 'field' => 'region_id', 'scope_table' => 'wf_regions', 'scope_key' => 'id', 'game_field' => 'game_id' ], /* * Region selector. */ 'relationships' => [ 'region_id' => [ 'label' => 'Region', 'type' => 'select_record', 'table' => 'wf_regions', 'value_field' => 'id', 'label_field' => 'name', 'game_field' => 'game_id', 'required' => true ] ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], /* * Config fields are stored inside the Location's config JSON. */ 'config_fields' => [ 'position' => [ 'label' => 'Position', 'type' => 'number', 'min' => 0, 'required' => true, /* * Position applies only to Locations in linear Regions. */ 'condition' => [ 'relationship' => 'region_id', 'source_table' => 'wf_regions', 'source_key' => 'id', 'source_field' => 'navigation_type', 'equals' => 'linear' ] ] ] ], // ================================================================ // ENTITIES // ================================================================ 'entities' => [ 'label' => 'Entities', 'singular' => 'Entity', 'table' => 'wf_entities', 'list_field' => 'name', /* * Enables author-created custom traits. */ 'traits' => true, 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'entity_type' => [ 'label' => 'Entity Type', 'type' => 'select', 'required' => true, 'default' => 'item', 'options' => [ 'item' => 'Item', 'character' => 'Character' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], /* |-------------------------------------------------------------------------- | ENTITY SYSTEM TRAITS |-------------------------------------------------------------------------- | | System traits are required properties managed by Wayfarer. | | Unlike custom traits: | | - They are created automatically. | - The author cannot remove them. | - Their names cannot be changed. | - Their data types cannot be changed. | - They appear as normal settings in the Entity editor rather | than inside the dynamic custom Traits table. | | selector_field determines which group of system traits applies. | | after_field tells the editor where these settings should appear. | */ 'system_traits' => [ 'selector_field' => 'entity_type', 'after_field' => 'entity_type', 'groups' => [ // -------------------------------------------------------- // ITEM SYSTEM TRAITS // -------------------------------------------------------- 'item' => [ 'label' => 'Item Settings', 'help' => 'These are required Item properties used by Wayfarer. ' . 'They cannot be removed or renamed.', 'traits' => [ // ------------------------------------------------ // QUANTITY // ------------------------------------------------ 'quantity' => [ 'label' => 'Quantity', 'type' => 'number', 'default' => 0, 'min' => 0, 'required' => true, 'removable' => false, 'locked_name' => true, 'locked_type' => true, 'help' => 'The quantity of this item the player has at ' . 'the beginning of the game. Quantity can change ' . 'during play. A starting quantity of 0 is valid.' ], // ------------------------------------------------ // INVENTORY VISIBILITY // ------------------------------------------------ 'inventory_visibility' => [ 'label' => 'Inventory Visibility', 'type' => 'select', 'default' => 'while_possessed', 'required' => true, 'removable' => false, 'locked_name' => true, 'locked_type' => true, 'options' => [ 'while_possessed' => 'Only while possessed', 'once_discovered' => 'Once discovered', 'always' => 'Always' ], 'help' => 'Controls when this item appears in the player inventory. ' . '"Only while possessed" hides the item whenever its quantity ' . 'is 0. "Once discovered" hides it until the player first ' . 'obtains or discovers it, then keeps it visible even if its ' . 'quantity later returns to 0. "Always" shows the item from ' . 'the beginning of the game, even when its quantity is 0.' ] ] ], // -------------------------------------------------------- // CHARACTER SYSTEM TRAITS // -------------------------------------------------------- 'character' => [ 'label' => 'Character Settings', 'help' => 'No required Character-specific traits are currently defined.', 'traits' => [] ] ] ] ], // ================================================================ // STORYLETS // ================================================================ 'storylets' => [ 'label' => 'Storylets', 'singular' => 'Storylet', 'table' => 'wf_storylets', 'list_field' => 'name', 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'trigger_type' => [ 'label' => 'Trigger Type', 'type' => 'select', 'required' => true, 'default' => 'manual', 'options' => [ 'manual' => 'Manual', 'automatic' => 'Automatic' ] ], 'max_occurrences' => [ 'label' => 'Maximum Occurrences', 'type' => 'number', 'min' => 1, 'help' => 'Leave blank for unlimited occurrences.' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ] ] ]; ================================================================================ FILE: _v4_backups/20260922-144120/includes/builder/editor.php ================================================================================

$relationship): ?> $field): ?> $field): ?>

Traits

Traits are values associated with this that can change during play.

================================================================================ FILE: _v4_backups/20260922-144120/includes/builder/save-record.php ================================================================================ $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } // ------------------------------------------------------------ // READ NORMAL FIELDS // ------------------------------------------------------------ foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } // ------------------------------------------------------------ // READ CONFIG FIELDS // ------------------------------------------------------------ foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } // ------------------------------------------------------------ // VALIDATE RELATIONSHIPS // ------------------------------------------------------------ foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } // ------------------------------------------------------------ // READ TRAITS // ------------------------------------------------------------ if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $traits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } // ------------------------------------------------------------ // VALIDATE NORMAL FIELDS // ------------------------------------------------------------ if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } // ------------------------------------------------------------ // VERIFY RELATIONSHIPS BELONG TO THIS GAME // ------------------------------------------------------------ if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } // ------------------------------------------------------------ // VALIDATE ACTIVE CONFIG FIELDS // ------------------------------------------------------------ if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues'=> $configValues, 'traits' => $traits, 'error' => $error, ]; } // ------------------------------------------------------------ // BUILD WRITE FIELD LIST // ------------------------------------------------------------ $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } // ------------------------------------------------------------ // BUILD CONFIG JSON // ------------------------------------------------------------ $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if (!empty($definition['traits'])) { $configToSave['traits'] = $traits; } if (!empty($configFields) || !empty($definition['traits'])) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } // ------------------------------------------------------------ // UPDATE // ------------------------------------------------------------ if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } // ------------------------------------------------------------ // INSERT // ------------------------------------------------------------ else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $values[] = $record[$fieldName]; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } // Important: traits can require config even when config_fields is empty. if (!empty($configFields) || !empty($definition['traits'])) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: _v55_backups/20260925-181055/includes/builder/editor.php ================================================================================ $groupValues) { if (!is_array($groupValues)) { continue; } foreach ($groupValues as $traitName => $traitValue) { if (!isset($storedSystemTraitValues[$groupName][$traitName])) { $storedSystemTraitValues[$groupName][$traitName] = $traitValue; } } } } } $renderSystemTraits = function () use ( $record, $fields, $systemTraitsSelector, $systemTraitGroups, $storedSystemTraitValues, $storedSystemTraitMaxValues ) { if (!$systemTraitsSelector || empty($systemTraitGroups)) { return; } $selectedGroup = $record[$systemTraitsSelector] ?? ($fields[$systemTraitsSelector]['default'] ?? ''); ?>
$groupDefinition): ?>
>

$traitDefinition): ?>

>

$relationship): ?> $field): ?>

Media

Optional. Reuse an existing asset from this game's Media Library, upload a new asset, or leave the slot empty.

$mediaDefinition): ?>

Maximum upload: .

Current: ()



No existing assets have been uploaded to this game yet.

$field): ?>


Traits

Traits are additional values associated with this that can change during play.


Danger Zone

Permanently delete this . This cannot be undone.

================================================================================ FILE: _v55_backups/20260925-181055/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } $allowedTargets = gameplayTickEffectAllowedTargets( $gameConfig['traits'] ?? [], $regionTraits, 'region', ($record['navigation_type'] ?? '') === 'linear' ); $allowedConditionTargets = gameplayConditionAllowedTargets( $gameConfig['traits'] ?? [], $regionTraits, 'region', 'Region' ); return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayConditionAllowedTargets( $gameConfig['traits'] ?? [], $regionConfig['traits'] ?? [], 'region', 'Region' ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayEffectAllowedTargets( $gameConfig['traits'] ?? [], $regionTraits, 'region', false ), $postedEffects, gameplayConditionAllowedTargets( $gameConfig['traits'] ?? [], $regionTraits, 'region', 'Region' ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayTickEffectAllowedTargets( $gameConfig['traits'] ?? [], $regionTraits, 'region', $allowLinearNavigation ); $allowedConditionTargets = gameplayConditionAllowedTargets( $gameConfig['traits'] ?? [], $regionTraits, 'region', 'Region' ); return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { $configToSave['traits'] = $traits; /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $values[] = $record[$fieldName]; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: _v55_backups/20260925-181055/includes/gameplay/starting-choices.php ================================================================================ prepare( " SELECT id, name, config FROM wf_entities WHERE game_id = ? AND status = 'active' AND LOWER(entity_type) = 'item' ORDER BY name, id " ); $stmt->execute([ $gameId ]); $rows = $stmt->fetchAll( PDO::FETCH_ASSOC ); $targets = []; foreach ($rows as $row) { $entityId = (int)( $row['id'] ?? 0 ); if ($entityId <= 0) { continue; } $config = []; if ( isset($row['config']) && $row['config'] !== null && $row['config'] !== '' ) { $decoded = json_decode( (string)$row['config'], true ); if (is_array($decoded)) { $config = $decoded; } } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; $quantityTraitId = ''; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( array_key_exists( 'active', $trait ) && !$trait['active'] ) { continue; } if ( empty($trait['system']) || (string)( $trait['system_key'] ?? '' ) !== 'quantity' ) { continue; } $quantityTraitId = trim( (string)( $trait['id'] ?? '' ) ); break; } if ($quantityTraitId === '') { continue; } $targets[] = [ 'entity_id' => $entityId, 'name' => trim( (string)( $row['name'] ?? ( 'Item ' . $entityId ) ) ), 'target_scope' => 'entity:' . $entityId, 'target_type' => 'trait', 'target_id' => $quantityTraitId, 'target_system_key' => 'quantity', ]; } return $targets; } function wayfarerStartingChoiceEffectTarget( array $effect ): string { if (!empty($effect['target'])) { return (string)$effect['target']; } $scope = (string)( $effect['target_scope'] ?? '' ); $type = (string)( $effect['target_type'] ?? '' ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); if ( $scope === 'game' && $type === 'trait' && $targetId !== '' ) { return 'game:' . $targetId; } if ( $scope === 'player' && $type === 'trait' && $targetId !== '' ) { $selector = strtolower( trim( (string)( $effect['target_selector'] ?? '' ) ) ); if ($selector === 'all') { return 'player_all:' . $targetId; } if ( $selector === 'slot' && array_key_exists( 'target_slot', $effect ) ) { return 'player_slot:' . max( 0, (int)$effect['target_slot'] ) . ':' . $targetId; } } if ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope ) === 1 ) { return $scope . ':' . $targetId; } return ''; } /* * Build the complete target map used by both authoring validation and the * Starting Choice target selector. */ function wayfarerStartingChoiceAllowedTargets( array $gameTraits, array $playerTraits = [], int $playerSlots = 0, array $itemQuantityTargets = [] ): array { $allowed = []; foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $trait ) { $traitId = (string)( $trait['id'] ?? '' ); if ($traitId === '') { continue; } $allowed[ 'game:' . $traitId ] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $traitId, 'group' => 'Game Traits', 'label' => 'Game → ' . (string)( $trait['name'] ?? $traitId ), ]; } $playerTargets = gameplayNumericTraitTargets( $playerTraits, 'player' ); $playerSlots = max( 0, $playerSlots ); if ( $playerSlots > 0 && !empty($playerTargets) ) { foreach ($playerTargets as $trait) { $traitId = (string)( $trait['id'] ?? '' ); if ($traitId === '') { continue; } $allowed[ 'player_all:' . $traitId ] = [ 'target_scope' => 'player', 'target_type' => 'trait', 'target_id' => $traitId, 'target_selector' => 'all', 'group' => 'Player Traits — All Players', 'label' => 'All Players → ' . (string)( $trait['name'] ?? $traitId ), ]; } for ( $slot = 0; $slot < $playerSlots; $slot++ ) { foreach ($playerTargets as $trait) { $traitId = (string)( $trait['id'] ?? '' ); if ($traitId === '') { continue; } $allowed[ 'player_slot:' . $slot . ':' . $traitId ] = [ 'target_scope' => 'player', 'target_type' => 'trait', 'target_id' => $traitId, 'target_selector' => 'slot', 'target_slot' => $slot, 'group' => 'Player Character ' . ($slot + 1), 'label' => 'Player ' . ($slot + 1) . ' → ' . (string)( $trait['name'] ?? $traitId ), ]; } } } foreach ($itemQuantityTargets as $itemTarget) { if (!is_array($itemTarget)) { continue; } $scope = trim( (string)( $itemTarget['target_scope'] ?? '' ) ); $targetId = trim( (string)( $itemTarget['target_id'] ?? '' ) ); $entityId = (int)( $itemTarget['entity_id'] ?? 0 ); if ( $entityId <= 0 || !preg_match( '/^entity:[1-9][0-9]*$/', $scope ) || $targetId === '' ) { continue; } $key = $scope . ':' . $targetId; $allowed[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $targetId, 'target_system_key' => 'quantity', 'group' => 'Items', 'label' => (string)( $itemTarget['name'] ?? ( 'Item ' . $entityId ) ) . ' → Quantity', ]; } return $allowed; } function wayfarerBuildStartingChoicesFromPost( array $postedChoices, array $gameTraits, array $playerTraits = [], int $playerSlots = 0, array $itemQuantityTargets = [] ): array { $allowedTargets = wayfarerStartingChoiceAllowedTargets( $gameTraits, $playerTraits, $playerSlots, $itemQuantityTargets ); /* * Legacy migration map: * * V52 allowed authors to move a definition from Game Traits to Player * Traits while keeping its Trait ID, but V36 Starting Choices still held * game:TRAIT_ID. When that exact ID is now a Player Trait, interpret the * old target as All Player Characters. */ $legacyPlayerTraitTargets = []; foreach ( gameplayNumericTraitTargets( $playerTraits, 'player' ) as $trait ) { $traitId = (string)( $trait['id'] ?? '' ); if ($traitId === '') { continue; } $legacyPlayerTraitTargets[ 'game:' . $traitId ] = 'player_all:' . $traitId; } $choices = []; $seenChoiceIds = []; foreach ( array_values($postedChoices) as $choiceIndex => $postedChoice ) { if (!is_array($postedChoice)) { continue; } $prompt = trim( (string)( $postedChoice['prompt'] ?? '' ) ); $postedOptions = isset($postedChoice['options']) && is_array($postedChoice['options']) ? array_values( $postedChoice['options'] ) : []; /* * Completely blank newly-added card: ignore it. */ if ( $prompt === '' && $postedOptions === [] ) { continue; } if ($prompt === '') { return [ 'choices' => [], 'error' => 'Each Starting Choice must have a prompt.', ]; } $choiceId = wayfarerStartingChoiceValidId( $postedChoice['id'] ?? null ) ? (string)$postedChoice['id'] : wayfarerStartingChoiceMakeId( 'choice' ); if (isset($seenChoiceIds[$choiceId])) { return [ 'choices' => [], 'error' => 'Starting Choice IDs must be unique.', ]; } $seenChoiceIds[$choiceId] = true; $required = !empty( $postedChoice['required'] ); $options = []; $seenOptionIds = []; foreach ( $postedOptions as $optionIndex => $postedOption ) { if (!is_array($postedOption)) { continue; } $label = trim( (string)( $postedOption['label'] ?? '' ) ); $description = trim( (string)( $postedOption['description'] ?? '' ) ); $postedEffects = isset($postedOption['effects']) && is_array($postedOption['effects']) ? array_values( $postedOption['effects'] ) : []; if ( $label === '' && $description === '' && $postedEffects === [] ) { continue; } if ($label === '') { return [ 'choices' => [], 'error' => 'Each Starting Choice option must have a label.', ]; } $optionId = wayfarerStartingChoiceValidId( $postedOption['id'] ?? null ) ? (string)$postedOption['id'] : wayfarerStartingChoiceMakeId( 'option' ); if (isset($seenOptionIds[$optionId])) { return [ 'choices' => [], 'error' => 'Starting Choice option IDs must be unique within a choice.', ]; } $seenOptionIds[$optionId] = true; $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $operation = trim( (string)( $postedEffect['operation'] ?? '' ) ); $valueRaw = trim( (string)( $postedEffect['value'] ?? '' ) ); /* * Empty newly-added effect row. */ if ( $target === '' && $operation === '' && $valueRaw === '' ) { continue; } /* * Preserve the intent of a Trait moved from Game -> Player. */ if ( !isset($allowedTargets[$target]) && isset( $legacyPlayerTraitTargets[$target] ) && isset( $allowedTargets[ $legacyPlayerTraitTargets[$target] ] ) ) { $target = $legacyPlayerTraitTargets[$target]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'choices' => [], 'error' => 'Each Starting Choice effect must use a valid Game or Player Trait target.', ]; } if ( !in_array( $operation, [ 'add', 'subtract', 'set', ], true ) ) { return [ 'choices' => [], 'error' => 'Starting Choice effects must Add, Subtract, or Set.', ]; } if ( $valueRaw === '' || !is_numeric($valueRaw) ) { return [ 'choices' => [], 'error' => 'Each Starting Choice effect must have a numeric value.', ]; } $value = filter_var( $valueRaw, FILTER_VALIDATE_INT ) !== false ? (int)$valueRaw : (float)$valueRaw; $targetDefinition = $allowedTargets[$target]; $effect = [ 'target_scope' => (string)$targetDefinition['target_scope'], 'target_type' => 'trait', 'target_id' => (string)$targetDefinition['target_id'], 'operation' => $operation, 'value' => $value, ]; if ( !empty( $targetDefinition[ 'target_system_key' ] ) ) { $effect['target_system_key'] = (string)$targetDefinition[ 'target_system_key' ]; } if ( $targetDefinition['target_scope'] === 'player' ) { $effect['target_selector'] = (string)( $targetDefinition[ 'target_selector' ] ?? 'all' ); if ( $effect['target_selector'] === 'slot' ) { $effect['target_slot'] = (int)( $targetDefinition[ 'target_slot' ] ?? 0 ); } } $effects[] = $effect; } $options[] = [ 'id' => $optionId, 'label' => $label, 'description' => $description, 'effects' => $effects, ]; } if ($options === []) { return [ 'choices' => [], 'error' => 'Each Starting Choice must have at least one option.', ]; } $choices[] = [ 'id' => $choiceId, 'prompt' => $prompt, 'required' => $required, 'options' => $options, ]; } return [ 'choices' => $choices, 'error' => '', ]; } function wayfarerRenderStartingChoiceTargetOptions( array $gameTraits, array $playerTraits = [], int $playerSlots = 0, array $itemQuantityTargets = [], string $selected = '' ): void { $targets = wayfarerStartingChoiceAllowedTargets( $gameTraits, $playerTraits, $playerSlots, $itemQuantityTargets ); /* * If an existing option still stores game:TRAIT_ID but that ID now lives * under Player Traits, present it as All Players instead of losing the * selection in the editor. */ if ( $selected !== '' && !isset($targets[$selected]) && strpos( $selected, 'game:' ) === 0 ) { $traitId = substr( $selected, strlen('game:') ); $candidate = 'player_all:' . $traitId; if (isset($targets[$candidate])) { $selected = $candidate; } } echo ''; $grouped = []; foreach ($targets as $value => $target) { $group = (string)( $target['group'] ?? 'Traits' ); if (!isset($grouped[$group])) { $grouped[$group] = []; } $grouped[$group][$value] = $target; } foreach ($grouped as $group => $options) { echo ''; foreach ($options as $value => $target) { echo ''; } echo ''; } } function wayfarerRenderStartingChoiceEffect( array $effect = [] ): void { $target = wayfarerStartingChoiceEffectTarget( $effect ); $operation = (string)( $effect['operation'] ?? 'set' ); $value = $effect['value'] ?? ''; ?>
Option
Starting Effects Optional. Effects may target numeric Game Traits or Player Traits.
Starting Choice

Starting Choices

Optional one-of-many questions shown before the game begins. The selected option is saved in runtime state. Option effects can adjust numeric Game Traits or Player Traits during initialization.

true, 'value' => wayfarerNormalizeRuntimeNumber( (float)$effectValue ), 'error' => '', ]; } return [ 'applied' => true, 'value' => $effectValue, 'error' => '', ]; } if ( $operation !== 'add' && $operation !== 'subtract' ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Unsupported operation: ' . $operation, ]; } if ( !is_numeric($currentValue) || !is_numeric($effectValue) ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Add/subtract requires numeric values.', ]; } $current = (float)$currentValue; $amount = (float)$effectValue; $result = $operation === 'add' ? $current + $amount : $current - $amount; return [ 'applied' => true, 'value' => wayfarerNormalizeRuntimeNumber( $result ), 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Tick Effect branch runtime |-------------------------------------------------------------------------- | | A Tick Effect owns one target and an ordered list of result branches. | Conditions are evaluated against the CURRENT runtime state. Because Tick | Effects execute sequentially, a later Tick Effect can see state changes made | by an earlier Tick Effect during the same tick. | */ function wayfarerRuntimeResolveConditionTarget( array $runtimeState, int $currentRegionId, string $scope, string $targetType, string $targetId ): array { $scope = strtolower( trim( $scope ) ); $targetType = strtolower( trim( $targetType ) ); $targetId = trim( $targetId ); // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $targetType === 'trait' && $targetId !== '' ) { $traits = $runtimeState['game']['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // SPECIFIC REGION TRAIT // WAYFARER_SPECIFIC_REGION_CONDITION_SCOPE_V46 // ------------------------------------------------------------ // // Game-level condition editors store a Region-owned Trait as // "region:". This lets a global rule inspect that Region whether // or not the player is currently there. // if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^region:([1-9][0-9]*)$/', $scope, $regionScopeMatch ) === 1 ) { $regionKey = (string)( (int)$regionScopeMatch[1] ); $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $targetType === 'trait' && $targetId !== '' && $currentRegionId > 0 ) { $regionKey = (string)$currentRegionId; $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $targetType === 'position' && $targetId === 'position' ) { if ( isset( $runtimeState['navigation'] ) && is_array( $runtimeState['navigation'] ) && array_key_exists( 'position', $runtimeState['navigation'] ) ) { return [ 'found' => true, 'value' => $runtimeState['navigation']['position'], ]; } return [ 'found' => false, 'value' => null, ]; } return [ 'found' => false, 'value' => null, ]; } function wayfarerRuntimeTickEffectBranches( array $effect ): array { /* * v28+ canonical structure. * * If the branches key exists, it is authoritative. An empty branches * array therefore means "this effect has no executable results" rather * than silently falling back to legacy compatibility fields. */ if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * Legacy pre-v28 structure. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'conditions', $effect ) ) { return [ [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $effect['operation'] ?? '', 'value' => $effect['value'] ?? 0, ] ]; } return []; } function wayfarerRuntimeSelectTickEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeTickEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Tick Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; /* * Validate before evaluating. Invalid authored data fails closed and * does NOT fall through to OTHERWISE, because doing so could silently * produce the wrong gameplay result. */ $validation = wayfarerConditionValidateNode( $conditions ); if (!$validation['valid']) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Tick Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Runtime Trait bounds |-------------------------------------------------------------------------- | | WAYFARER_RUNTIME_TRAIT_BOUNDS_V30 | | Authored numeric Trait min/max values are runtime invariants. | State is normalized before condition evaluation and every subsequent | mutation is clamped again before it is written. | */ function wayfarerRuntimeDecodeTraitConfig( $rawConfig ): array { if (is_array($rawConfig)) { return $rawConfig; } if ( $rawConfig === null || $rawConfig === '' ) { return []; } $decoded = json_decode( (string)$rawConfig, true ); return is_array($decoded) ? $decoded : []; } function wayfarerRuntimeIndexTraitDefinitions( array $traits ): array { $definitions = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $definitions[$traitId] = $trait; } return $definitions; } function wayfarerRuntimeLoadTickTraitDefinitions( PDO $pdo, int $gameId, array $regionConfig ): array { $gameConfig = []; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawGameConfig = $stmt->fetchColumn(); if ($rawGameConfig !== false) { $gameConfig = wayfarerRuntimeDecodeTraitConfig( $rawGameConfig ); } } return [ 'game' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['traits'] ?? null ) ? $gameConfig['traits'] : [] ), /* WAYFARER_STARTING_CHOICE_PLAYER_TARGETS_V53 */ 'player' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['player_traits'] ?? null ) ? $gameConfig['player_traits'] : [] ), 'region' => wayfarerRuntimeIndexTraitDefinitions( is_array( $regionConfig['traits'] ?? null ) ? $regionConfig['traits'] : [] ), ]; } function wayfarerRuntimeClampValueToTraitDefinition( $value, ?array $definition ): array { $result = [ 'valid' => true, 'value' => $value, 'clamped' => false, 'min' => null, 'max' => null, 'error' => '', ]; /* * Bounds currently belong only to authored Number Traits. */ if ( !$definition || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' ) { return $result; } if (!is_numeric($value)) { $result['valid'] = false; $result['error'] = 'Numeric Trait runtime state must be numeric.'; return $result; } $min = isset($definition['min']) && $definition['min'] !== '' && is_numeric($definition['min']) ? (float)$definition['min'] : null; $max = isset($definition['max']) && $definition['max'] !== '' && is_numeric($definition['max']) ? (float)$definition['max'] : null; $numeric = (float)$value; $bounded = $numeric; if ( $min !== null && $bounded < $min ) { $bounded = $min; } if ( $max !== null && $bounded > $max ) { $bounded = $max; } $result['value'] = wayfarerNormalizeRuntimeNumber( $bounded ); $result['clamped'] = $bounded !== $numeric; $result['min'] = $min === null ? null : wayfarerNormalizeRuntimeNumber( $min ); $result['max'] = $max === null ? null : wayfarerNormalizeRuntimeNumber( $max ); return $result; } function wayfarerRuntimeApplyBoundedTraitOperation( $storedBefore, string $operation, $effectValue, ?array $definition ): array { /* * Repair an already-illegal persisted value BEFORE applying the new * operation. Example: 120 with max 100 and subtract 10 becomes * 100 - 10 = 90, not 120 - 10 = 110 -> 100. */ $beforeBound = wayfarerRuntimeClampValueToTraitDefinition( $storedBefore, $definition ); if (!$beforeBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $storedBefore, 'requested_after' => $storedBefore, 'after' => $storedBefore, 'clamped' => false, 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $beforeBound['error'], ]; } $normalizedBefore = $beforeBound['value']; $operationResult = wayfarerApplyRuntimeOperation( $normalizedBefore, $operation, $effectValue ); if (!$operationResult['applied']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $normalizedBefore, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $operationResult['error'], ]; } $requestedAfter = $operationResult['value']; $afterBound = wayfarerRuntimeClampValueToTraitDefinition( $requestedAfter, $definition ); if (!$afterBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $afterBound['error'], ]; } return [ 'applied' => true, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $afterBound['value'], 'clamped' => $beforeBound['clamped'] || $afterBound['clamped'], 'min' => $afterBound['min'], 'max' => $afterBound['max'], 'error' => '', ]; } function wayfarerRuntimeClampApplicableTraitState( array &$runtimeState, array $traitDefinitions, int $currentRegionId ): array { $repairs = []; // ------------------------------------------------------------ // GAME TRAITS // ------------------------------------------------------------ $gameTraits = $runtimeState['game']['traits'] ?? null; if (is_array($gameTraits)) { foreach ( $traitDefinitions['game'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $gameTraits ) ) { continue; } $before = $runtimeState['game']['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['game']['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } // ------------------------------------------------------------ // CURRENT REGION TRAITS // ------------------------------------------------------------ $regionKey = (string)$currentRegionId; $regionTraits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if (is_array($regionTraits)) { foreach ( $traitDefinitions['region'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $regionTraits ) ) { continue; } $before = $runtimeState['regions'][$regionKey]['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } return $repairs; } function wayfarerApplyRuntimeTickEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectTickEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection['branch_index'], 'applied' => false, 'error' => $selection['error'], ]; } /* * No conditional branch matched and there was no OTHERWISE. * This is a valid "do nothing this tick" result. */ if (!$selection['matched']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection['branch_index']; $operation = strtolower( trim( (string)( $branch['operation'] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; /* * Explicit No Change: * the branch DID match, so this Tick Effect stops here, but state does * not mutate. */ if ($operation === 'none') { $current = wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $type, $targetId ); return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'amount' => null, 'before' => $current['found'] ? $current['value'] : null, 'after' => $current['found'] ? $current['value'] : null, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'applied' => false, 'no_change' => true, 'error' => '', ]; } /* * Apply ONLY the selected result branch. The legacy effect-level * operation/value fields are deliberately ignored when branches exist. */ $resolvedEffect = $effect; $resolvedEffect['operation'] = $operation; $resolvedEffect['value'] = $branch['value'] ?? 0; $report = wayfarerApplyRuntimeEffect( $runtimeState, $resolvedEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; return $report; } function wayfarerApplyRuntimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); $operation = strtolower( trim( (string)( $effect['operation'] ?? '' ) ) ); $effectValue = $effect['value'] ?? 0; // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $type === 'position' && $targetId === 'position' ) { if ( !isset($runtimeState['navigation']) || !is_array($runtimeState['navigation']) ) { $runtimeState['navigation'] = []; } $before = $runtimeState['navigation']['position'] ?? 0; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); $after = $before; if ( $result['applied'] && !is_numeric($result['value']) ) { $result['applied'] = false; $result['error'] = 'Navigation position must be numeric.'; } if ($result['applied']) { /* * Global navigation rule: * position may never be less than zero. */ $after = max( 0, $result['value'] ); $runtimeState['navigation']['position'] = $after; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $after, 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['game']) || !is_array($runtimeState['game']) ) { $runtimeState['game'] = []; } if ( !isset($runtimeState['game']['traits']) || !is_array($runtimeState['game']['traits']) ) { $runtimeState['game']['traits'] = []; } $before = $runtimeState['game']['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['game'][$targetId] ) && is_array( $traitDefinitions['game'][$targetId] ) ? $traitDefinitions['game'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); /* * Persist the normalized value even when a malformed operation fails * after an existing illegal value was repaired. */ if ( $result['normalized_before'] !== $before ) { $runtimeState['game']['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['game']['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // ITEM ENTITY QUANTITY // WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 // ------------------------------------------------------------ if ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityScopeMatch ) === 1 && ( (string)( $effect['target_system_key'] ?? '' ) === 'quantity' || $targetId === 'sys_item_quantity' ) ) { $entityKey = (string)( (int)$entityScopeMatch[1] ); if ( !isset( $runtimeState['entities'][$entityKey] ) || !is_array( $runtimeState['entities'][$entityKey] ) || !isset( $runtimeState['entities'][$entityKey]['traits'] ) || !is_array( $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Entity runtime state was not initialized.', ]; } if ( !array_key_exists( $targetId, $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Quantity runtime state was not found.', ]; } $before = $runtimeState['entities'][$entityKey]['traits'][$targetId]; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); if ( !$result['applied'] || !is_numeric( $result['value'] ?? null ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $before, 'applied' => false, 'error' => $result['error'] !== '' ? $result['error'] : 'Item Quantity must remain numeric.', ]; } $requestedAfter = wayfarerNormalizeRuntimeNumber( (float)$result['value'] ); /* * Quantity is globally non-negative. */ $after = wayfarerNormalizeRuntimeNumber( max( 0.0, (float)$requestedAfter ) ); $runtimeState['entities'][$entityKey]['traits'][$targetId] = $after; /* * Discovery is persistent. Receiving any positive starting quantity * discovers the Item; setting it back to zero never undiscoveres it. */ if ( (float)$after > 0 ) { $runtimeState['entities'][$entityKey]['discovered'] = true; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_system_key' => 'quantity', 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'requested_after' => $requestedAfter, 'after' => $after, 'clamped' => (float)$after !== (float)$requestedAfter, 'min' => 0, 'max' => null, 'applied' => true, 'error' => '', ]; } // ------------------------------------------------------------ // PLAYER TRAIT // WAYFARER_STARTING_CHOICE_PLAYER_TARGETS_V53 // ------------------------------------------------------------ if ( $scope === 'player' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['players']) || !is_array($runtimeState['players']) ) { $runtimeState['players'] = []; } $selector = strtolower( trim( (string)( $effect['target_selector'] ?? '' ) ) ); $targetKeys = []; if ($selector === 'all') { foreach ( $runtimeState['players'] as $playerKey => $playerState ) { if (is_array($playerState)) { $targetKeys[] = (string)$playerKey; } } } elseif ( $selector === 'slot' && array_key_exists( 'target_slot', $effect ) ) { $slot = max( 0, (int)$effect['target_slot'] ); $targetKeys[] = (string)$slot; } else { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect requires an All Players or Player Slot target.', ]; } if (empty($targetKeys)) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect did not resolve any Player Characters.', ]; } $definition = isset( $traitDefinitions['player'][$targetId] ) && is_array( $traitDefinitions['player'][$targetId] ) ? $traitDefinitions['player'][$targetId] : null; $results = []; $firstError = ''; foreach ($targetKeys as $playerKey) { if ( !isset( $runtimeState['players'][$playerKey] ) || !is_array( $runtimeState['players'][$playerKey] ) ) { $firstError = 'Player Character slot ' . ((int)$playerKey + 1) . ' does not exist in runtime state.'; $results[$playerKey] = [ 'applied' => false, 'error' => $firstError, ]; break; } if ( !isset( $runtimeState['players'][$playerKey]['traits'] ) || !is_array( $runtimeState['players'][$playerKey]['traits'] ) ) { $runtimeState['players'][$playerKey]['traits'] = []; } $before = $runtimeState['players'][$playerKey]['traits'][$targetId] ?? 0; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['after']; } $results[$playerKey] = [ 'slot' => (int)$playerKey, 'name' => (string)( $runtimeState['players'][$playerKey]['name'] ?? ( 'Player ' . ((int)$playerKey + 1) ) ), 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; if ( !$result['applied'] || $result['error'] !== '' ) { $firstError = $result['error'] !== '' ? $result['error'] : 'Player Trait effect could not be applied.'; break; } } $allApplied = $firstError === ''; return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_selector' => $selector, 'target_slot' => $selector === 'slot' ? (int)( $effect['target_slot'] ?? 0 ) : null, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'results' => $results, 'applied' => $allApplied, 'error' => $firstError, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $type === 'trait' && $targetId !== '' ) { $regionKey = (string)$currentRegionId; if ( !isset($runtimeState['regions']) || !is_array($runtimeState['regions']) ) { $runtimeState['regions'] = []; } if ( !isset($runtimeState['regions'][$regionKey]) || !is_array($runtimeState['regions'][$regionKey]) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset($runtimeState['regions'][$regionKey]['traits']) || !is_array($runtimeState['regions'][$regionKey]['traits']) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } $before = $runtimeState['regions'][$regionKey]['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['region'][$targetId] ) && is_array( $traitDefinitions['region'][$targetId] ) ? $traitDefinitions['region'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Unsupported runtime target.', ]; } /* |-------------------------------------------------------------------------- | Game Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_GAME_TICK_EFFECT_RUNTIME_V33 | | Game Tick Effects execute once on every successful runtime tick before | the active Region's Tick Effects. This creates a deterministic layering: | | Game rules -> Region rules | */ function wayfarerApplyGameTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { if ($gameId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active game is available for Game Tick Effects.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { $reports = [ [ 'applied' => false, 'error' => 'Game ' . $gameId . ' was not found.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $gameConfig = []; if ( $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, [] ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $gameEffects = $gameConfig['tick_effects'] ?? []; if (!is_array($gameEffects)) { $gameEffects = []; } $reports = []; foreach ($gameEffects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Game Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; $runtimeState['runtime']['last_game_trait_bound_repairs'] = $boundRepairs; return $reports; } /* |-------------------------------------------------------------------------- | Generalized one-time Effect runtime | WAYFARER_GENERALIZED_EFFECT_RUNTIME_V43 |-------------------------------------------------------------------------- | | Conditions use the same shared evaluator as Tick Effect branches. | Random values are resolved exactly once, immediately before the selected | result mutates runtime state. | */ function wayfarerRuntimeEffectBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * v42 compatibility: one Effect was one unconditional operation/value. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $branch = [ 'conditions' => null, 'operation' => $effect['operation'] ?? 'add', 'value_mode' => $effect['value_mode'] ?? 'fixed', ]; if ( strtolower( trim( (string)( $branch[ 'value_mode' ] ) ) ) === 'random' ) { $branch['value_min'] = $effect['value_min'] ?? 0; $branch['value_max'] = $effect['value_max'] ?? 0; } else { $branch['value'] = $effect['value'] ?? 0; } return [ $branch ]; } return []; } function wayfarerRuntimeSelectEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; $validation = wayfarerConditionValidateNode( $conditions ); if ( !$validation['valid'] ) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } function wayfarerRuntimeRollEffectValue( array $branch ): array { $mode = strtolower( trim( (string)( $branch[ 'value_mode' ] ?? 'fixed' ) ) ); if ($mode !== 'random') { $value = $branch['value'] ?? null; if (!is_numeric($value)) { return [ 'valid' => false, 'mode' => 'fixed', 'value' => null, 'min' => null, 'max' => null, 'error' => 'Fixed Effect value must be numeric.', ]; } return [ 'valid' => true, 'mode' => 'fixed', 'value' => wayfarerNormalizeRuntimeNumber( (float)$value ), 'min' => null, 'max' => null, 'error' => '', ]; } $minimum = $branch['value_min'] ?? null; $maximum = $branch['value_max'] ?? null; if ( !is_numeric($minimum) || !is_numeric($maximum) ) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => $minimum, 'max' => $maximum, 'error' => 'Random Effect requires numeric Min and Max values.', ]; } $min = (float)$minimum; $max = (float)$maximum; if ($min > $max) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => 'Random Effect Min cannot be greater than Max.', ]; } if ( floor($min) === $min && floor($max) === $max ) { $rolled = random_int( (int)$min, (int)$max ); } else { /* * Continuous decimal range. Six decimal places of entropy are more * than sufficient for authored simulation values and preserve generic * numeric Min/Max support without requiring authored precision metadata. */ $fraction = random_int( 0, 1000000 ) / 1000000; $rolled = $min + (($max - $min) * $fraction); } return [ 'valid' => true, 'mode' => 'random', 'value' => wayfarerNormalizeRuntimeNumber( (float)$rolled ), 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => '', ]; } function wayfarerApplyRuntimeOneTimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect[ 'target_scope' ] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect[ 'target_type' ] ?? '' ) ) ); $targetId = trim( (string)( $effect[ 'target_id' ] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection[ 'branch_index' ], 'applied' => false, 'error' => $selection[ 'error' ], ]; } if ( !$selection['matched'] ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection[ 'branch_index' ]; $operation = strtolower( trim( (string)( $branch[ 'operation' ] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; if ($operation === 'none') { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => null, 'rolled_value' => null, 'applied' => false, 'error' => '', ]; } $resolved = wayfarerRuntimeRollEffectValue( $branch ); if (!$resolved['valid']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => $resolved[ 'mode' ], 'rolled_value' => null, 'random_min' => $resolved[ 'min' ], 'random_max' => $resolved[ 'max' ], 'applied' => false, 'error' => $resolved[ 'error' ], ]; } $concreteEffect = [ 'target_scope' => $scope, 'target_type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'value' => $resolved[ 'value' ], ]; $report = wayfarerApplyRuntimeEffect( $runtimeState, $concreteEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; $report['value_mode'] = $resolved[ 'mode' ]; $report['rolled_value'] = $resolved[ 'value' ]; if ( $resolved['mode'] === 'random' ) { $report['random_min'] = $resolved[ 'min' ]; $report['random_max'] = $resolved[ 'max' ]; } return $report; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECT_RUNTIME_V42 | | These are event Effects, not Tick Effects. The caller decides that a | Location has been triggered and invokes this function exactly once for that | trigger. Effects execute top-to-bottom against current runtime state. | */ function wayfarerApplyLocationEffects( PDO $pdo, int $gameId, array $location, array &$runtimeState ): array { $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeTraitConfig( $location['config'] ?? null ); $effects = $locationConfig['effects'] ?? []; if (!is_array($effects)) { $effects = []; } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false ) { $regionConfig = wayfarerRuntimeDecodeTraitConfig( $rawRegionConfig ); } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeOneTimeEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_location_effects'] = [ 'location_id' => $locationId, 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), 'reports' => $reports, 'bound_repairs' => $boundRepairs, ]; return $reports; } /* |-------------------------------------------------------------------------- | Current Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECT_RUNTIME_V35 | | Linear Regions resolve a current Location by exact authored Position, | matching the player-scene resolver. Location Tick Effects run after Region | Tick Effects, so movement performed by the Region can cause arrival effects | at the newly reached Location on the same tick. | */ function wayfarerRuntimeFindCurrentLocation( PDO $pdo, int $gameId, array $runtimeState ): ?array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ( $gameId <= 0 || $regionId <= 0 ) { return null; } $position = $runtimeState['navigation']['position'] ?? null; if (!is_numeric($position)) { return null; } $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($locations as $location) { $config = []; if ( !empty( $location['config'] ) ) { $decoded = json_decode( (string)$location['config'], true ); if (is_array($decoded)) { $config = $decoded; } } if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } if ( (float)$config['position'] === (float)$position ) { $location['_runtime_config'] = $config; /* * WAYFARER_LOCATION_CONDITIONS_CURRENT_V45 */ if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } return $location; } } return null; } function wayfarerApplyCurrentLocationTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } /* * Being between authored Locations is normal. Record an empty result * rather than treating the absence of a Location as an error. */ if (!$location) { $runtimeState['runtime']['last_location_id'] = null; $runtimeState['runtime']['last_location_tick_effects'] = []; return []; } $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : []; $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); /* * Location effects may target Current Region Traits, so load the active * Region's Trait definitions rather than the Location's authored Traits. */ $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false && $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $locationEffects = $locationConfig['tick_effects'] ?? []; if (!is_array($locationEffects)) { $locationEffects = []; } $reports = []; foreach ( $locationEffects as $effect ) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } $runtimeState['runtime']['last_location_id'] = $locationId; $runtimeState['runtime']['last_location_tick_effects'] = $reports; $runtimeState['runtime']['last_location_trait_bound_repairs'] = $boundRepairs; return $reports; } function wayfarerApplyCurrentRegionTickEffects( PDO $pdo, array &$runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ($regionId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active navigation region.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_tick_effects'] = $reports; return $reports; } $stmt = $pdo->prepare( " SELECT id, game_id, config FROM wf_regions WHERE id = ? LIMIT 1 " ); $stmt->execute([ $regionId ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { $reports = [ [ 'applied' => false, 'error' => 'Active Region ' . $regionId . ' was not found.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_tick_effects'] = $reports; return $reports; } $config = []; if (!empty($region['config'])) { $decoded = json_decode( $region['config'], true ); if (is_array($decoded)) { $config = $decoded; } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, (int)( $region['game_id'] ?? 0 ), $config ); /* * Enforce the invariant before any branch condition is evaluated. * This also repairs state produced by older runtime versions. */ $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $regionId ); $effects = $config['tick_effects'] ?? []; if (!is_array($effects)) { $effects = []; } $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed tick effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $regionId, $traitDefinitions ); } if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_tick_effects'] = $reports; $runtimeState['runtime']['last_trait_bound_repairs'] = $boundRepairs; return $reports; } ================================================================================ FILE: _v55_backups/20260925-181055/play/includes/entity-state.php ================================================================================ [ * TRAIT_ID => CURRENT_VALUE, * ], * 'discovered' => true|false, // Items only * ]; * * The helper is intentionally additive. Existing state wins. */ function wayfarerEntityDecodeConfig($rawConfig): array { if (is_array($rawConfig)) { return $rawConfig; } if ( $rawConfig === null || $rawConfig === '' ) { return []; } $decoded = json_decode( (string)$rawConfig, true ); return is_array($decoded) ? $decoded : []; } function wayfarerEntityActiveTraitDefinitions( array $config ): array { $definitions = []; $traits = $config['traits'] ?? []; if (!is_array($traits)) { return []; } foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( array_key_exists( 'active', $trait ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $trait['_runtime_id'] = $traitId; $definitions[$traitId] = $trait; } return $definitions; } function wayfarerEntitySystemTraitId( array $definitions, string $systemKey ): ?string { foreach ( $definitions as $traitId => $definition ) { if ( !is_array($definition) || empty($definition['system']) ) { continue; } if ( (string)( $definition['system_key'] ?? '' ) === $systemKey ) { return (string)$traitId; } } return null; } function wayfarerEntityTraitShownOn( array $definition, string $surface ): bool { if (!empty($definition['system'])) { return false; } $surfaces = $definition['display']['surfaces'] ?? []; return is_array($surfaces) && in_array( $surface, $surfaces, true ); } function wayfarerEntityDisplayValue($value): string { if (is_bool($value)) { return $value ? 'Yes' : 'No'; } if ($value === null) { return '—'; } if (is_array($value)) { $json = json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); return $json === false ? '—' : $json; } return (string)$value; } function wayfarerRuntimeLoadActiveEntities( PDO $pdo, int $gameId ): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare( " SELECT id, name, description, entity_type, status, config FROM wf_entities WHERE game_id = ? AND status = 'active' ORDER BY name, id " ); $stmt->execute([ $gameId ]); $rows = $stmt->fetchAll( PDO::FETCH_ASSOC ); return is_array($rows) ? $rows : []; } /* * Additive/recovery-safe Entity initialization. * * Returns true only when runtime state was changed. */ function wayfarerRuntimeEnsureEntityState( PDO $pdo, int $gameId, array &$runtimeState ): bool { if ($gameId <= 0) { return false; } $changed = false; if ( !isset($runtimeState['entities']) || !is_array( $runtimeState['entities'] ) ) { $runtimeState['entities'] = []; $changed = true; } foreach ( wayfarerRuntimeLoadActiveEntities( $pdo, $gameId ) as $entity ) { $entityId = (int)( $entity['id'] ?? 0 ); if ($entityId <= 0) { continue; } $entityKey = (string)$entityId; $config = wayfarerEntityDecodeConfig( $entity['config'] ?? null ); $definitions = wayfarerEntityActiveTraitDefinitions( $config ); if ( !isset( $runtimeState['entities'][$entityKey] ) || !is_array( $runtimeState['entities'][$entityKey] ) ) { $runtimeState['entities'][$entityKey] = []; $changed = true; } if ( !isset( $runtimeState['entities'][$entityKey]['traits'] ) || !is_array( $runtimeState['entities'][$entityKey]['traits'] ) ) { $runtimeState['entities'][$entityKey]['traits'] = []; $changed = true; } foreach ( $definitions as $traitId => $definition ) { if ( array_key_exists( $traitId, $runtimeState['entities'][$entityKey]['traits'] ) ) { continue; } /* * Reuse Wayfarer's canonical initial-value semantics. */ $runtimeState['entities'][$entityKey]['traits'][$traitId] = wayfarerRuntimeInitialTraitValue( $definition ); $changed = true; } if ( strtolower( trim( (string)( $entity['entity_type'] ?? '' ) ) ) !== 'item' ) { continue; } $quantityTraitId = wayfarerEntitySystemTraitId( $definitions, 'quantity' ); $quantity = $quantityTraitId !== null ? ( $runtimeState['entities'][$entityKey]['traits'] [$quantityTraitId] ?? 0 ) : 0; /* * Discovery is persistent. Possessing the Item at any recovery point * discovers it; returning to zero later never clears discovery. */ if ( !array_key_exists( 'discovered', $runtimeState['entities'][$entityKey] ) ) { $runtimeState['entities'][$entityKey]['discovered'] = is_numeric($quantity) && (float)$quantity > 0; $changed = true; } elseif ( !$runtimeState['entities'][$entityKey]['discovered'] && is_numeric($quantity) && (float)$quantity > 0 ) { $runtimeState['entities'][$entityKey]['discovered'] = true; $changed = true; } } return $changed; } /* * Resolve the player-facing Inventory and Status models. * * Presentation metadata never changes whether a Trait exists in runtime. */ /* * WAYFARER_ENTITY_DETAIL_VIEW_V50 * * Authored media links use the underlying table name as object_type. * Entity images therefore use object_type = wf_entities. */ function wayfarerEntityImageUrl( PDO $pdo, int $gameId, int $entityId ): string { if ( $gameId <= 0 || $entityId <= 0 ) { return ''; } $stmt = $pdo->prepare( " SELECT m.relative_path FROM wf_media_links ml INNER JOIN wf_media m ON m.id = ml.media_id WHERE ml.object_type = 'wf_entities' AND ml.object_id = ? AND ml.slot = 'image' AND m.game_id = ? AND m.media_type = 'image' LIMIT 1 " ); $stmt->execute([ $entityId, $gameId, ]); $relativePath = $stmt->fetchColumn(); if ( $relativePath === false || $relativePath === null || $relativePath === '' ) { return ''; } $relativePath = ltrim( str_replace( '\\', '/', (string)$relativePath ), '/' ); /* * Media uploads live beneath the Wayfarer root. play/ is one level below. * Reject traversal rather than attempting to normalize it. */ if ( $relativePath === '' || strpos( $relativePath, '..' ) !== false ) { return ''; } return '../' . $relativePath; } function wayfarerRuntimeBuildEntityPresentation( PDO $pdo, int $gameId, array $runtimeState, array $gameConfig = [] ): array { $result = [ 'inventory' => [], 'party' => [], ]; /* * WAYFARER_PLAYER_TRAITS_RUNTIME_V52 * Game and built-in Player presentation now lives in player-state.php. * This resolver owns only Item Inventory and joined Character Entities. */ foreach ( wayfarerRuntimeLoadActiveEntities( $pdo, $gameId ) as $entity ) { $entityId = (int)( $entity['id'] ?? 0 ); if ($entityId <= 0) { continue; } $entityKey = (string)$entityId; $entityState = $runtimeState['entities'][$entityKey] ?? []; $traitState = is_array( $entityState['traits'] ?? null ) ? $entityState['traits'] : []; $config = wayfarerEntityDecodeConfig( $entity['config'] ?? null ); $definitions = wayfarerEntityActiveTraitDefinitions( $config ); $entityType = strtolower( trim( (string)( $entity['entity_type'] ?? '' ) ) ); /* * -------------------------------------------------------------- * INVENTORY * -------------------------------------------------------------- */ if ($entityType === 'item') { $quantityTraitId = wayfarerEntitySystemTraitId( $definitions, 'quantity' ); $visibilityTraitId = wayfarerEntitySystemTraitId( $definitions, 'inventory_visibility' ); $quantity = $quantityTraitId !== null ? ( $traitState[$quantityTraitId] ?? 0 ) : 0; $visibility = $visibilityTraitId !== null ? (string)( $traitState[$visibilityTraitId] ?? ( $definitions[$visibilityTraitId]['initial'] ?? 'while_possessed' ) ) : 'while_possessed'; $discovered = !empty( $entityState['discovered'] ); $show = false; switch ($visibility) { case 'always': $show = true; break; case 'never': $show = false; break; case 'once_discovered': $show = $discovered; break; case 'while_possessed': default: $show = is_numeric($quantity) && (float)$quantity > 0; break; } if (!$show) { continue; } $visibleTraits = []; foreach ( $definitions as $traitId => $definition ) { if ( !wayfarerEntityTraitShownOn( $definition, 'inventory' ) || !array_key_exists( $traitId, $traitState ) ) { continue; } $visibleTraits[] = [ 'id' => $traitId, 'name' => (string)( $definition['name'] ?? $traitId ), 'value' => wayfarerEntityDisplayValue( $traitState[$traitId] ), ]; } $result['inventory'][] = [ 'id' => $entityId, 'name' => (string)( $entity['name'] ?? 'Item' ), 'description' => trim( (string)( $entity['description'] ?? '' ) ), 'image_url' => wayfarerEntityImageUrl( $pdo, $gameId, $entityId ), 'quantity' => wayfarerEntityDisplayValue( $quantity ), 'traits' => $visibleTraits, ]; continue; } /* * -------------------------------------------------------------- * STATUS / PARTY * -------------------------------------------------------------- */ if ($entityType === 'character') { $partyTraitId = wayfarerEntitySystemTraitId( $definitions, 'party_member' ); $inParty = $partyTraitId !== null && !empty( $traitState[$partyTraitId] ); if (!$inParty) { continue; } $visibleTraits = []; foreach ( $definitions as $traitId => $definition ) { if ( !wayfarerEntityTraitShownOn( $definition, 'status' ) || !array_key_exists( $traitId, $traitState ) ) { continue; } $visibleTraits[] = [ 'id' => $traitId, 'name' => (string)( $definition['name'] ?? $traitId ), 'value' => wayfarerEntityDisplayValue( $traitState[$traitId] ), ]; } $result['party'][] = [ 'id' => $entityId, 'name' => (string)( $entity['name'] ?? 'Character' ), 'description' => trim( (string)( $entity['description'] ?? '' ) ), 'image_url' => wayfarerEntityImageUrl( $pdo, $gameId, $entityId ), 'traits' => $visibleTraits, ]; } } return $result; } ================================================================================ FILE: _v55_backups/20260925-181055/play/includes/runtime.php ================================================================================ prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? ORDER BY id " ); $stmt->execute([ $gameId ]); $regions = $stmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($regions as $region) { $regionId = (int)( $region['id'] ?? 0 ); if ($regionId <= 0) { continue; } $regionKey = (string)$regionId; $config = wayfarerRuntimeDecodeConfig( $region['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset( $runtimeState['regions'][$regionKey] ) || !is_array( $runtimeState['regions'][$regionKey] ) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset( $runtimeState['regions'][$regionKey]['traits'] ) || !is_array( $runtimeState['regions'][$regionKey]['traits'] ) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['regions'][$regionKey]['traits'] ) ) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $initialValue; } } } function wayfarerRuntimeLoadStartingRegion( PDO $pdo, int $gameId, ?array $gameConfig = null ): ?array { if ($gameId <= 0) { return null; } if ($gameConfig === null) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { return null; } $gameConfig = wayfarerRuntimeDecodeConfig( $rawConfig ); } $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT * FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $startingRegionId, $gameId, ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); return $region ?: null; } function wayfarerRuntimeInitializeGame( PDO $pdo, int $gameUserId, array $setupInput = [] ): array { if ($gameUserId <= 0) { throw new RuntimeException( 'A valid game-user ID is required to initialize the game.' ); } try { $pdo->beginTransaction(); /* * Lock the playthrough row so two initialize requests cannot create * competing starting states. */ $stmt = $pdo->prepare( " SELECT gu.id, gu.game_id, gu.user_id, gu.role, gu.state, g.config AS game_config, g.player_slots AS player_slots FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id WHERE gu.id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } /* * Initialization is intentionally one-way. To restart a development * game, clear wf_game_users.state to NULL first. */ if ( $row['state'] !== null && $row['state'] !== '' ) { throw new RuntimeException( 'This game-user already has runtime state.' ); } $gameId = (int)$row['game_id']; $gameConfig = wayfarerRuntimeDecodeConfig( $row['game_config'] ?? null ); $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { throw new RuntimeException( 'This Game does not have a Starting Region configured.' ); } $startingRegion = wayfarerRuntimeLoadStartingRegion( $pdo, $gameId, $gameConfig ); if (!$startingRegion) { throw new RuntimeException( 'The configured Starting Region does not exist for this Game.' ); } $regionConfig = wayfarerRuntimeDecodeConfig( $startingRegion['config'] ?? null ); $gameTraits = wayfarerRuntimeInitialTraitState( $gameConfig ); $regionTraits = wayfarerRuntimeInitialTraitState( $regionConfig ); $navigation = [ 'region_id' => (int)$startingRegion['id'], ]; /* * Linear travel currently begins at coordinate zero. * Other navigation modes will get their own authored start-state * rules when those modes are implemented. */ if ( strtolower( trim( (string)( $startingRegion['navigation_type'] ?? '' ) ) ) === 'linear' ) { $navigation['position'] = 0; } $regionKey = (string)$startingRegion['id']; $state = [ 'game' => [ 'traits' => $gameTraits, ], 'navigation' => $navigation, 'regions' => [ $regionKey => [ 'traits' => $regionTraits, ], ], 'runtime' => [ 'tick' => 0, ], ]; /* * WAYFARER_ALL_REGION_STATE_V46 * * Global conditions can inspect Region state before that Region has * ever been visited. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_V49 * * Items and Characters receive canonical per-playthrough state before * any starting setup effects are applied. */ wayfarerRuntimeEnsureEntityState( $pdo, $gameId, $state ); /* * WAYFARER_APPLY_STARTING_CHOICES_V36 * * Validate all player-entered setup data before persisting anything. * Then apply the selected options' starting effects to the canonical * initial runtime state. */ $preparedSetup = wayfarerRuntimePrepareStartingSetup( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $setupInput ); /* * WAYFARER_PLAYER_TRAITS_RUNTIME_V52 * * Persist the validated player-character names before creating the * per-player Trait state. Each slot receives independent values. */ $state['setup'] = [ 'player_names' => $preparedSetup['player_names'], 'choices' => $preparedSetup['choices'], ]; wayfarerRuntimeEnsureGameTraitState( $gameConfig, $state ); wayfarerRuntimeEnsurePlayerState( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $state ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $startingEffectReports = []; foreach ( $preparedSetup['effects'] as $startingEffect ) { $report = wayfarerApplyRuntimeEffect( $state, $startingEffect, (int)$startingRegion['id'], $traitDefinitions ); if ( !empty($report['error']) ) { throw new RuntimeException( 'Unable to apply a Starting Choice effect: ' . $report['error'] ); } $startingEffectReports[] = $report; } $state['runtime']['starting_effects'] = $startingEffectReports; /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 * * A game that begins exactly on an active Location should present that * Location before ordinary Action Mode. */ $startingLocation = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $state ); if ($startingLocation) { /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Starting on a Location is a real Location trigger. Apply its * one-time Effects before opening Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, $gameId, $startingLocation, $state ); $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$startingLocation['id'], 'phase' => 'presentation', 'trigger' => 'initial', 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /* WAYFARER_GAME_ENDINGS_INITIAL_V45 */ wayfarerRuntimeEvaluateGameEndings( $pdo, $gameId, $state ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => $gameId, 'starting_region_id' => (int)$startingRegion['id'], 'state' => $state, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Resolve a game-user record for the tick prototype. * * Preferred: * explicit wf_game_users.id * * Temporary development fallback: * first wf_game_users record. * * The fallback exists only because Wayfarer has not yet wired the play * page to authentication / game selection. Once that exists, remove the * fallback and always pass the active game_user_id explicitly. */ function wayfarerRuntimeResolveGameUserId( PDO $pdo, ?int $requestedId = null ): int { if ($requestedId !== null && $requestedId > 0) { $stmt = $pdo->prepare( " SELECT id FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $requestedId ]); $id = $stmt->fetchColumn(); if ($id !== false) { return (int)$id; } throw new RuntimeException( 'The requested game-user record does not exist.' ); } $id = $pdo ->query( " SELECT id FROM wf_game_users ORDER BY id LIMIT 1 " ) ->fetchColumn(); if ($id === false) { throw new RuntimeException( 'No wf_game_users record exists.' ); } return (int)$id; } /** * Load one game-user runtime state. */ function wayfarerRuntimeLoad( PDO $pdo, int $gameUserId ): array { $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'state' => $state, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /** * Advance exactly one tick. * * This is intentionally the entire v14 game engine: * * state.runtime.tick += 1 * * The transaction and row lock are already in place so later work can add * movement/effects without changing the request architecture. */ /* |-------------------------------------------------------------------------- | Linear Location crossing | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | A tick may move farther than the distance to the next authored Location. | This resolver finds the FIRST active Location crossed in the direction of | travel. The movement is then clamped to that exact authored coordinate. | | Examples: | 45 -> 55, Location 50 => stop at 50 | 55 -> 45, Location 50 => stop at 50 | | The starting coordinate is intentionally exclusive so leaving a Location | does not immediately trigger that same Location again. | */ /* |-------------------------------------------------------------------------- | Location Conditions | WAYFARER_LOCATION_CONDITIONS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeLocationConditionsPass( PDO $pdo, int $gameId, array $location, array &$runtimeState ): bool { $config = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $conditions = $config['conditions'] ?? null; $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; return wayfarerConditionsPass( $conditions, $resolver ); } /* |-------------------------------------------------------------------------- | Game Endings | WAYFARER_GAME_ENDINGS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeEvaluateGameEndings( PDO $pdo, int $gameId, array &$runtimeState ): ?array { /* * WAYFARER_ALL_REGION_STATE_ENDINGS_V46 */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $runtimeState ); if ( isset( $runtimeState['runtime']['ending'] ) && is_array( $runtimeState['runtime']['ending'] ) ) { return $runtimeState['runtime']['ending']; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); $config = wayfarerRuntimeDecodeConfig( $raw === false ? null : $raw ); $endings = $config['endings'] ?? []; if (!is_array($endings)) { return null; } $ordered = []; foreach ( array_values($endings) as $index => $ending ) { if (!is_array($ending)) { continue; } $ending['_runtime_order'] = $index; $ordered[] = $ending; } usort( $ordered, function ( array $a, array $b ): int { $priorityCompare = (int)( $b['priority'] ?? 0 ) <=> (int)( $a['priority'] ?? 0 ); if ($priorityCompare !== 0) { return $priorityCompare; } return (int)( $a['_runtime_order'] ?? 0 ) <=> (int)( $b['_runtime_order'] ?? 0 ); } ); $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; foreach ($ordered as $ending) { if ( !wayfarerConditionsPass( $ending['conditions'] ?? null, $resolver ) ) { continue; } $terminal = [ 'id' => (string)( $ending['id'] ?? '' ), 'title' => (string)( $ending['title'] ?? 'The End' ), 'text' => (string)( $ending['text'] ?? '' ), 'image_media_id' => (int)( $ending['image_media_id'] ?? 0 ), 'priority' => (int)( $ending['priority'] ?? 0 ), 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), ]; if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['ending'] = $terminal; unset( $runtimeState['runtime']['presentation'] ); return $terminal; } return null; } function wayfarerRuntimeFindCrossedLocation( PDO $pdo, int $gameId, int $regionId, $fromPosition, $toPosition, array &$runtimeState ): ?array { /* * WAYFARER_CROSSED_LOCATION_STATE_FIX_V46_1 * * Location Conditions are evaluated against the live runtime state. */ if ( $gameId <= 0 || $regionId <= 0 || !is_numeric($fromPosition) || !is_numeric($toPosition) ) { return null; } $from = (float)$fromPosition; $to = (float)$toPosition; if ($from === $to) { return null; } /* * Crossing interception currently applies only to Linear Regions. */ $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); $forward = $to > $from; $candidate = null; $candidatePosition = null; foreach ($locations as $location) { $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $locationPosition = (float)$config['position']; /* * The starting point is exclusive; the destination is inclusive. */ $crossed = $forward ? ( $locationPosition > $from && $locationPosition <= $to ) : ( $locationPosition < $from && $locationPosition >= $to ); if (!$crossed) { continue; } $location['_runtime_config'] = $config; if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } if ($candidate === null) { $candidate = $location; $candidatePosition = $locationPosition; } else { $nearer = $forward ? ( $locationPosition < $candidatePosition ) : ( $locationPosition > $candidatePosition ); $samePositionLowerId = $locationPosition === $candidatePosition && (int)$location['id'] < (int)$candidate['id']; if ( $nearer || $samePositionLowerId ) { $candidate = $location; $candidatePosition = $locationPosition; } } } if ($candidate === null) { return null; } $candidate['_runtime_position'] = wayfarerNormalizeRuntimeNumber( (float)$candidatePosition ); $candidate['_runtime_config'] = wayfarerRuntimeDecodeConfig( $candidate['config'] ?? null ); return $candidate; } /* |-------------------------------------------------------------------------- | Player spatial context |-------------------------------------------------------------------------- | WAYFARER_PLAYER_CONTEXT_V46_1 | | This is the canonical player-facing spatial label for Action Mode: | | exact eligible Location -> Location name + Region parent context | otherwise -> Region name | | It is returned by runtime APIs so the browser does not keep displaying the | Location name from the page's original server render after movement. | */ function wayfarerRuntimePlayerContext( PDO $pdo, int $gameId, array $runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionName = ''; if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $rawRegionName = $stmt->fetchColumn(); if ($rawRegionName !== false) { $regionName = trim( (string)$rawRegionName ); } } $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); $locationId = 0; $locationName = ''; if ($location) { $locationId = (int)( $location['id'] ?? 0 ); $locationName = trim( (string)( $location['name'] ?? '' ) ); } $contextName = $locationName !== '' ? $locationName : ( $regionName !== '' ? $regionName : 'Actions' ); return [ 'region_id' => $regionId, 'region_name' => $regionName, 'location_id' => $locationId, 'location_name' => $locationName, 'context_name' => $contextName, 'parent_context' => $locationName !== '' ? $regionName : '', ]; } /* |-------------------------------------------------------------------------- | Presentation dismissal |-------------------------------------------------------------------------- | | Presentation is persisted runtime state. This function is intentionally | generic even though v40 renders only Location presentations; Storylets can | use the same mechanism later. | */ function wayfarerRuntimeDismissPresentation( PDO $pdo, int $gameUserId, string $expectedType = '', int $expectedId = 0 ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); $presentation = $state['runtime']['presentation'] ?? null; if ( !is_array($presentation) || empty($presentation['type']) || empty($presentation['id']) ) { throw new RuntimeException( 'There is no active presentation to continue.' ); } if ( $expectedType !== '' && (string)$presentation['type'] !== $expectedType ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } if ( $expectedId > 0 && (int)$presentation['id'] !== $expectedId ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } $state['runtime']['last_presentation'] = $presentation; unset( $state['runtime']['presentation'] ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'dismissed' => $presentation, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Advance exactly one tick. * * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ function wayfarerRuntimeAdvanceTick( PDO $pdo, int $gameUserId ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } /* * WAYFARER_GAME_ENDINGS_TERMINAL_V45 * * Endings are terminal runtime state. */ if ( isset( $state['runtime']['ending'] ) && is_array( $state['runtime']['ending'] ) ) { throw new RuntimeException( 'This game has ended.' ); } /* * Narrative presentation is a true runtime pause, not merely a hidden * browser control. Even Author Mode cannot advance ticks through it. */ if ( isset( $state['runtime']['presentation'] ) && is_array( $state['runtime']['presentation'] ) ) { throw new RuntimeException( 'Continue the current presentation before advancing time.' ); } /* * WAYFARER_ALL_REGION_STATE_TICK_V46 * * Recovery path for playthroughs initialized before v46, and for * Regions/Traits authored after a playthrough began. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, (int)$row['game_id'], $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_TICK_V49 * * Recovery path for playthroughs created before V49 and for Entities * or Traits authored after play began. */ wayfarerRuntimeEnsureEntityState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_PLAYER_TRAITS_TICK_RECOVERY_V52 */ wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$row['game_id'], $state ); $regionIdBeforeTick = (int)( $state['navigation']['region_id'] ?? 0 ); $positionBeforeTick = $state['navigation']['position'] ?? null; $currentTick = (int)( $state['runtime']['tick'] ?? 0 ); $state['runtime']['tick'] = $currentTick + 1; /* * Global Game Tick Effects run first. */ wayfarerApplyGameTickEffects( $pdo, (int)$row['game_id'], $state ); /* * Current Region Tick Effects then perform Region-level movement and * state changes. */ wayfarerApplyCurrentRegionTickEffects( $pdo, $state ); /* * Resolve whether the completed movement would have crossed an active * Location. If so, stop at the nearest crossed Location rather than * accepting the overshot position. */ $regionIdAfterMovement = (int)( $state['navigation']['region_id'] ?? 0 ); $positionAfterMovement = $state['navigation']['position'] ?? null; $crossedLocation = null; /* * We only interpret movement as a continuous path while the player * remains in the same Region. Region transitions will later get their * own explicit navigation semantics. */ if ( $regionIdBeforeTick > 0 && $regionIdBeforeTick === $regionIdAfterMovement ) { $crossedLocation = wayfarerRuntimeFindCrossedLocation( $pdo, (int)$row['game_id'], $regionIdAfterMovement, $positionBeforeTick, $positionAfterMovement, $state ); } $arrivalPosition = null; if ($crossedLocation) { $arrivalPosition = $crossedLocation[ '_runtime_position' ]; $state['navigation']['position'] = $arrivalPosition; /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Event Effects execute once at the trigger boundary. They run * before the Location's Tick Effects and before Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, (int)$row['game_id'], $crossedLocation, $state ); } /* * WAYFARER_LOCATION_EFFECTS_ONLY_RUNTIME_V43 * * Locations no longer own Tick Effects. Game and Region Tick Effects * have already advanced the simulation; a crossed Location contributes * its one-time Effects and then interrupts with Presentation Mode. */ if ($crossedLocation) { $state['navigation']['position'] = $arrivalPosition; $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$crossedLocation['id'], 'phase' => 'presentation', 'trigger' => 'arrival', 'tick' => (int)$state['runtime']['tick'], ]; $state['runtime']['last_location_arrival'] = [ 'location_id' => (int)$crossedLocation['id'], 'region_id' => $regionIdAfterMovement, 'position' => $arrivalPosition, 'tick' => (int)$state['runtime']['tick'], ]; } /* WAYFARER_GAME_ENDINGS_AFTER_TICK_V45 */ $ending = wayfarerRuntimeEvaluateGameEndings( $pdo, (int)$row['game_id'], $state ); $state['runtime']['last_tick_at'] = gmdate('c'); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId ]); $pdo->commit(); $presentation = $state['runtime']['presentation'] ?? null; $pausedForPresentation = is_array($presentation) && !empty($presentation['type']) && !empty($presentation['id']); $ending = $state['runtime']['ending'] ?? null; $ended = is_array($ending) && !empty($ending['id']); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'tick' => (int)$state['runtime']['tick'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'pause' => $pausedForPresentation || $ended, 'pause_reason' => $ended ? 'Game ended.' : ( $pausedForPresentation ? 'Location reached.' : '' ), 'presentation' => $pausedForPresentation ? $presentation : null, 'ending' => $ended ? $ending : null, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: _v5_backups/20260922-145724/config/builder-types.php ================================================================================ [ 'label' => 'Regions', 'singular' => 'Region', 'table' => 'wf_regions', 'list_field' => 'name', 'traits' => true, 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'navigation_type' => [ 'label' => 'Navigation Type', 'type' => 'select', 'required' => true, 'options' => [ 'linear' => 'Linear', 'grid' => 'Grid', 'freeform' => 'Freeform' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ] ], 'locations' => [ 'label' => 'Locations', 'singular' => 'Location', 'table' => 'wf_locations', 'list_field' => 'name', 'traits' => true, 'list_columns' => [ 'name' => [ 'label' => 'Name', 'source' => 'field', 'field' => 'name', 'sortable' => true ], 'region' => [ 'label' => 'Region', 'source' => 'relationship', 'relationship' => 'region_id', 'sortable' => true ], 'position' => [ 'label' => 'Position', 'source' => 'config', 'field' => 'position', 'type' => 'number', 'sortable' => true ] ], 'scope' => [ 'type' => 'region', 'field' => 'region_id', 'scope_table' => 'wf_regions', 'scope_key' => 'id', 'game_field' => 'game_id' ], 'relationships' => [ 'region_id' => [ 'label' => 'Region', 'type' => 'select_record', 'table' => 'wf_regions', 'value_field' => 'id', 'label_field' => 'name', 'game_field' => 'game_id', 'required' => true ] ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], 'config_fields' => [ 'position' => [ 'label' => 'Position', 'type' => 'number', 'min' => 0, 'required' => true, 'condition' => [ 'relationship' => 'region_id', 'source_table' => 'wf_regions', 'source_key' => 'id', 'source_field' => 'navigation_type', 'equals' => 'linear' ] ] ] ], 'entities' => [ 'label' => 'Entities', 'singular' => 'Entity', 'table' => 'wf_entities', 'list_field' => 'name', 'traits' => true, 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'entity_type' => [ 'label' => 'Entity Type', 'type' => 'select', 'required' => true, 'default' => 'item', 'options' => [ 'item' => 'Item', 'character' => 'Character' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], /* * System traits use the same stored config.traits collection as * author-created traits. They are locked in the authoring UI. */ 'system_traits' => [ 'selector_field' => 'entity_type', 'after_field' => 'entity_type', 'groups' => [ 'item' => [ 'label' => 'Item Settings', 'help' => 'These are required Item properties used by Wayfarer. ' . 'They cannot be removed or renamed.', 'traits' => [ 'quantity' => [ 'label' => 'Quantity', 'type' => 'number', 'default' => 0, 'min' => 0, 'required' => true, 'help' => 'The quantity of this item the player has at ' . 'the beginning of the game. Quantity can change ' . 'during play. A starting quantity of 0 is valid.' ], 'inventory_visibility' => [ 'label' => 'Inventory Visibility', 'type' => 'select', 'default' => 'while_possessed', 'required' => true, 'options' => [ 'while_possessed' => 'Only while possessed', 'once_discovered' => 'Once discovered', 'always' => 'Always' ], 'help' => 'Controls when this item appears in the player inventory. ' . '"Only while possessed" hides the item whenever its quantity ' . 'is 0. "Once discovered" hides it until the player first ' . 'obtains or discovers it, then keeps it visible even if its ' . 'quantity later returns to 0. "Always" shows the item from ' . 'the beginning of the game, even when its quantity is 0.' ] ] ] ] ] ], 'storylets' => [ 'label' => 'Storylets', 'singular' => 'Storylet', 'table' => 'wf_storylets', 'list_field' => 'name', 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'trigger_type' => [ 'label' => 'Trigger Type', 'type' => 'select', 'required' => true, 'default' => 'manual', 'options' => [ 'manual' => 'Manual', 'automatic' => 'Automatic' ] ], 'max_occurrences' => [ 'label' => 'Maximum Occurrences', 'type' => 'number', 'min' => 1, 'help' => 'Leave blank for unlimited occurrences.' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ] ] ]; ================================================================================ FILE: _v5_backups/20260922-145724/includes/builder/editor.php ================================================================================ $groupValues) { if (!is_array($groupValues)) { continue; } foreach ($groupValues as $traitName => $traitValue) { if (!isset($storedSystemTraitValues[$groupName][$traitName])) { $storedSystemTraitValues[$groupName][$traitName] = $traitValue; } } } } } $renderSystemTraits = function () use ( $record, $fields, $systemTraitsSelector, $systemTraitGroups, $storedSystemTraitValues ) { if (!$systemTraitsSelector || empty($systemTraitGroups)) { return; } $selectedGroup = $record[$systemTraitsSelector] ?? ($fields[$systemTraitsSelector]['default'] ?? ''); ?>
$groupDefinition): ?>
>

$traitDefinition): ?>

$relationship): ?> $field): ?> $field): ?>

Traits

Traits are additional values associated with this that can change during play.

================================================================================ FILE: _v5_backups/20260922-145724/includes/builder/save-record.php ================================================================================ 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $traitDefinition['max'] ?? null, 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { $configToSave['traits'] = $traits; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $values[] = $record[$fieldName]; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: _v6_backups/20260922-150326/config/builder-types.php ================================================================================ [ 'label' => 'Regions', 'singular' => 'Region', 'table' => 'wf_regions', 'list_field' => 'name', 'traits' => true, 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'navigation_type' => [ 'label' => 'Navigation Type', 'type' => 'select', 'required' => true, 'options' => [ 'linear' => 'Linear', 'grid' => 'Grid', 'freeform' => 'Freeform' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ] ], 'locations' => [ 'label' => 'Locations', 'singular' => 'Location', 'table' => 'wf_locations', 'list_field' => 'name', 'traits' => true, 'list_columns' => [ 'name' => [ 'label' => 'Name', 'source' => 'field', 'field' => 'name', 'sortable' => true ], 'region' => [ 'label' => 'Region', 'source' => 'relationship', 'relationship' => 'region_id', 'sortable' => true ], 'position' => [ 'label' => 'Position', 'source' => 'config', 'field' => 'position', 'type' => 'number', 'sortable' => true ] ], 'scope' => [ 'type' => 'region', 'field' => 'region_id', 'scope_table' => 'wf_regions', 'scope_key' => 'id', 'game_field' => 'game_id' ], 'relationships' => [ 'region_id' => [ 'label' => 'Region', 'type' => 'select_record', 'table' => 'wf_regions', 'value_field' => 'id', 'label_field' => 'name', 'game_field' => 'game_id', 'required' => true ] ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], 'config_fields' => [ 'position' => [ 'label' => 'Position', 'type' => 'number', 'min' => 0, 'required' => true, 'condition' => [ 'relationship' => 'region_id', 'source_table' => 'wf_regions', 'source_key' => 'id', 'source_field' => 'navigation_type', 'equals' => 'linear' ] ] ] ], 'entities' => [ 'label' => 'Entities', 'singular' => 'Entity', 'table' => 'wf_entities', 'list_field' => 'name', 'traits' => true, 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'entity_type' => [ 'label' => 'Entity Type', 'type' => 'select', 'required' => true, 'default' => 'item', 'options' => [ 'item' => 'Item', 'character' => 'Character' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], /* * System traits use the same stored config.traits collection as * author-created traits. They are locked in the authoring UI. */ 'system_traits' => [ 'selector_field' => 'entity_type', 'after_field' => 'entity_type', 'groups' => [ 'item' => [ 'label' => 'Item Settings', 'help' => 'These are required Item properties used by Wayfarer. ' . 'They cannot be removed or renamed.', 'traits' => [ 'quantity' => [ 'label' => 'Quantity', 'type' => 'number', 'default' => 0, 'min' => 0, 'required' => true, 'help' => 'The quantity of this item the player has at ' . 'the beginning of the game. Quantity can change ' . 'during play. A starting quantity of 0 is valid.' ], 'inventory_visibility' => [ 'label' => 'Inventory Visibility', 'type' => 'select', 'default' => 'while_possessed', 'required' => true, 'options' => [ 'while_possessed' => 'Only while possessed', 'once_discovered' => 'Once discovered', 'always' => 'Always' ], 'help' => 'Controls when this item appears in the player inventory. ' . '"Only while possessed" hides the item whenever its quantity ' . 'is 0. "Once discovered" hides it until the player first ' . 'obtains or discovers it, then keeps it visible even if its ' . 'quantity later returns to 0. "Always" shows the item from ' . 'the beginning of the game, even when its quantity is 0.' ] ] ], 'character' => [ 'label' => 'Character Settings', 'help' => 'These are required Character properties used by Wayfarer. ' . 'They cannot be removed or renamed.', 'traits' => [ 'party_member' => [ 'label' => 'Starts in Party', 'type' => 'binary', 'default' => 0, 'required' => true, 'help' => 'Determines whether this character begins the game ' . 'as a member of the player party. No stores 0; ' . 'Yes stores 1. Storylets can later change this ' . 'state to add or remove the character from the party.' ] ] ] ] ] ], 'storylets' => [ 'label' => 'Storylets', 'singular' => 'Storylet', 'table' => 'wf_storylets', 'list_field' => 'name', 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'trigger_type' => [ 'label' => 'Trigger Type', 'type' => 'select', 'required' => true, 'default' => 'manual', 'options' => [ 'manual' => 'Manual', 'automatic' => 'Automatic' ] ], 'max_occurrences' => [ 'label' => 'Maximum Occurrences', 'type' => 'number', 'min' => 1, 'help' => 'Leave blank for unlimited occurrences.' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ] ] ]; ================================================================================ FILE: _v6_backups/20260922-150326/includes/builder/editor.php ================================================================================ $groupValues) { if (!is_array($groupValues)) { continue; } foreach ($groupValues as $traitName => $traitValue) { if (!isset($storedSystemTraitValues[$groupName][$traitName])) { $storedSystemTraitValues[$groupName][$traitName] = $traitValue; } } } } } $renderSystemTraits = function () use ( $record, $fields, $systemTraitsSelector, $systemTraitGroups, $storedSystemTraitValues ) { if (!$systemTraitsSelector || empty($systemTraitGroups)) { return; } $selectedGroup = $record[$systemTraitsSelector] ?? ($fields[$systemTraitsSelector]['default'] ?? ''); ?>
$groupDefinition): ?>
>

$traitDefinition): ?>

$relationship): ?> $field): ?> $field): ?>

Traits

Traits are additional values associated with this that can change during play.

================================================================================ FILE: _v6_backups/20260922-150326/includes/builder/save-record.php ================================================================================ 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $traitDefinition['max'] ?? null, 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { $configToSave['traits'] = $traits; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $values[] = $record[$fieldName]; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: _v7_backups/20260922-151006/admin/builder-refactored.php ================================================================================ prepare(" SELECT id, name FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; // ================================================================ // SAVE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder


+ New




← Games

================================================================================ FILE: _v7_backups/20260922-151006/config/builder-types.php ================================================================================ [ 'label' => 'Regions', 'singular' => 'Region', 'table' => 'wf_regions', 'list_field' => 'name', 'traits' => true, 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'navigation_type' => [ 'label' => 'Navigation Type', 'type' => 'select', 'required' => true, 'options' => [ 'linear' => 'Linear', 'grid' => 'Grid', 'freeform' => 'Freeform' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ] ], 'locations' => [ 'label' => 'Locations', 'singular' => 'Location', 'table' => 'wf_locations', 'list_field' => 'name', 'traits' => true, 'list_columns' => [ 'name' => [ 'label' => 'Name', 'source' => 'field', 'field' => 'name', 'sortable' => true ], 'region' => [ 'label' => 'Region', 'source' => 'relationship', 'relationship' => 'region_id', 'sortable' => true ], 'position' => [ 'label' => 'Position', 'source' => 'config', 'field' => 'position', 'type' => 'number', 'sortable' => true ] ], 'scope' => [ 'type' => 'region', 'field' => 'region_id', 'scope_table' => 'wf_regions', 'scope_key' => 'id', 'game_field' => 'game_id' ], 'relationships' => [ 'region_id' => [ 'label' => 'Region', 'type' => 'select_record', 'table' => 'wf_regions', 'value_field' => 'id', 'label_field' => 'name', 'game_field' => 'game_id', 'required' => true ] ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], 'config_fields' => [ 'position' => [ 'label' => 'Position', 'type' => 'number', 'min' => 0, 'required' => true, 'condition' => [ 'relationship' => 'region_id', 'source_table' => 'wf_regions', 'source_key' => 'id', 'source_field' => 'navigation_type', 'equals' => 'linear' ] ] ] ], 'entities' => [ 'label' => 'Entities', 'singular' => 'Entity', 'table' => 'wf_entities', 'list_field' => 'name', 'traits' => true, 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'entity_type' => [ 'label' => 'Entity Type', 'type' => 'select', 'required' => true, 'default' => 'item', 'options' => [ 'item' => 'Item', 'character' => 'Character' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], /* * System traits use the same stored config.traits collection as * author-created traits. They are locked in the authoring UI. */ 'system_traits' => [ 'selector_field' => 'entity_type', 'after_field' => 'entity_type', 'groups' => [ 'item' => [ 'label' => 'Item Settings', 'help' => 'These are required Item properties used by Wayfarer. ' . 'They cannot be removed or renamed.', 'traits' => [ 'quantity' => [ 'label' => 'Starting Quantity', 'type' => 'number', 'default' => 0, 'min' => 0, 'required' => true, 'allow_author_max' => true, 'max_label' => 'Maximum Quantity', 'max_help' => 'Optional. Leave blank for no maximum quantity.', 'help' => 'The quantity of this item the player has at ' . 'the beginning of the game. Quantity can change ' . 'during play. A starting quantity of 0 is valid.' ], 'inventory_visibility' => [ 'label' => 'Inventory Visibility', 'type' => 'select', 'default' => 'while_possessed', 'required' => true, 'options' => [ 'while_possessed' => 'Only while possessed', 'once_discovered' => 'Once discovered', 'always' => 'Always' ], 'help' => 'Controls when this item appears in the player inventory. ' . '"Only while possessed" hides the item whenever its quantity ' . 'is 0. "Once discovered" hides it until the player first ' . 'obtains or discovers it, then keeps it visible even if its ' . 'quantity later returns to 0. "Always" shows the item from ' . 'the beginning of the game, even when its quantity is 0.' ] ] ], 'character' => [ 'label' => 'Character Settings', 'help' => 'These are required Character properties used by Wayfarer. ' . 'They cannot be removed or renamed.', 'traits' => [ 'party_member' => [ 'label' => 'Starts in Party', 'type' => 'binary', 'default' => 0, 'required' => true, 'help' => 'Determines whether this character begins the game ' . 'as a member of the player party. No stores 0; ' . 'Yes stores 1. Storylets can later change this ' . 'state to add or remove the character from the party.' ] ] ] ] ] ], 'storylets' => [ 'label' => 'Storylets', 'singular' => 'Storylet', 'table' => 'wf_storylets', 'list_field' => 'name', 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'trigger_type' => [ 'label' => 'Trigger Type', 'type' => 'select', 'required' => true, 'default' => 'manual', 'options' => [ 'manual' => 'Manual', 'automatic' => 'Automatic' ] ], 'max_occurrences' => [ 'label' => 'Maximum Occurrences', 'type' => 'number', 'min' => 1, 'help' => 'Leave blank for unlimited occurrences.' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ] ] ]; ================================================================================ FILE: _v7_backups/20260922-151006/includes/builder/editor.php ================================================================================ $groupValues) { if (!is_array($groupValues)) { continue; } foreach ($groupValues as $traitName => $traitValue) { if (!isset($storedSystemTraitValues[$groupName][$traitName])) { $storedSystemTraitValues[$groupName][$traitName] = $traitValue; } } } } } $renderSystemTraits = function () use ( $record, $fields, $systemTraitsSelector, $systemTraitGroups, $storedSystemTraitValues, $storedSystemTraitMaxValues ) { if (!$systemTraitsSelector || empty($systemTraitGroups)) { return; } $selectedGroup = $record[$systemTraitsSelector] ?? ($fields[$systemTraitsSelector]['default'] ?? ''); ?>
$groupDefinition): ?>
>

$traitDefinition): ?>

$relationship): ?> $field): ?> $field): ?>

Traits

Traits are additional values associated with this that can change during play.

================================================================================ FILE: _v7_backups/20260922-151006/includes/builder/functions.php ================================================================================ prepare($sql); $stmt->execute([$gameId]); return $stmt->fetchAll(); } function buildScopeQuery(array $definition): array { $table = $definition['table']; $scope = $definition['scope']; if ($scope['type'] === 'game') { return [ 'from' => "`$table`", 'where' => "`$table`.`{$scope['field']}` = ?", ]; } $scopeTable = $scope['scope_table']; $scopeField = $scope['field']; $scopeKey = $scope['scope_key']; $gameField = $scope['game_field']; return [ 'from' => " `$table` INNER JOIN `$scopeTable` ON `$table`.`$scopeField` = `$scopeTable`.`$scopeKey` ", 'where' => "`$scopeTable`.`$gameField` = ?", ]; } function getRelatedFieldValue( PDO $pdo, array $condition, $relationshipValue ) { if ($relationshipValue === '' || $relationshipValue === null) { return null; } $table = $condition['source_table']; $sourceKey = $condition['source_key']; $sourceField = $condition['source_field']; $sql = " SELECT `$sourceField` FROM `$table` WHERE `$sourceKey` = ? LIMIT 1 "; $stmt = $pdo->prepare($sql); $stmt->execute([$relationshipValue]); $value = $stmt->fetchColumn(); return $value === false ? null : $value; } function configFieldApplies( PDO $pdo, array $field, array $record ): bool { if (empty($field['condition'])) { return true; } $condition = $field['condition']; $relationshipName = $condition['relationship'] ?? null; if (!$relationshipName) { return false; } $relationshipValue = $record[$relationshipName] ?? ''; $sourceValue = getRelatedFieldValue( $pdo, $condition, $relationshipValue ); return (string)$sourceValue === (string)$condition['equals']; } function builderEscape($value): string { return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); } ================================================================================ FILE: _v8_backups/20260922-153106/admin/builder-refactored.php ================================================================================ prepare(" SELECT id, name FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; // ================================================================ // SAVE / DELETE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder


+ New




← Games

================================================================================ FILE: _v8_backups/20260922-153106/includes/builder/editor.php ================================================================================ $groupValues) { if (!is_array($groupValues)) { continue; } foreach ($groupValues as $traitName => $traitValue) { if (!isset($storedSystemTraitValues[$groupName][$traitName])) { $storedSystemTraitValues[$groupName][$traitName] = $traitValue; } } } } } $renderSystemTraits = function () use ( $record, $fields, $systemTraitsSelector, $systemTraitGroups, $storedSystemTraitValues, $storedSystemTraitMaxValues ) { if (!$systemTraitsSelector || empty($systemTraitGroups)) { return; } $selectedGroup = $record[$systemTraitsSelector] ?? ($fields[$systemTraitsSelector]['default'] ?? ''); ?>
$groupDefinition): ?>
>

$traitDefinition): ?>

$relationship): ?> $field): ?> $field): ?>

Traits

Traits are additional values associated with this that can change during play.


Danger Zone

Permanently delete this . This cannot be undone.

================================================================================ FILE: _v8_backups/20260922-153106/includes/builder/functions.php ================================================================================ prepare($sql); $stmt->execute([$gameId]); return $stmt->fetchAll(); } function buildScopeQuery(array $definition): array { $table = $definition['table']; $scope = $definition['scope']; if ($scope['type'] === 'game') { return [ 'from' => "`$table`", 'where' => "`$table`.`{$scope['field']}` = ?", ]; } $scopeTable = $scope['scope_table']; $scopeField = $scope['field']; $scopeKey = $scope['scope_key']; $gameField = $scope['game_field']; return [ 'from' => " `$table` INNER JOIN `$scopeTable` ON `$table`.`$scopeField` = `$scopeTable`.`$scopeKey` ", 'where' => "`$scopeTable`.`$gameField` = ?", ]; } function getRelatedFieldValue( PDO $pdo, array $condition, $relationshipValue ) { if ($relationshipValue === '' || $relationshipValue === null) { return null; } $table = $condition['source_table']; $sourceKey = $condition['source_key']; $sourceField = $condition['source_field']; $sql = " SELECT `$sourceField` FROM `$table` WHERE `$sourceKey` = ? LIMIT 1 "; $stmt = $pdo->prepare($sql); $stmt->execute([$relationshipValue]); $value = $stmt->fetchColumn(); return $value === false ? null : $value; } function configFieldApplies( PDO $pdo, array $field, array $record ): bool { if (empty($field['condition'])) { return true; } $condition = $field['condition']; $relationshipName = $condition['relationship'] ?? null; if (!$relationshipName) { return false; } $relationshipValue = $record[$relationshipName] ?? ''; $sourceValue = getRelatedFieldValue( $pdo, $condition, $relationshipValue ); return (string)$sourceValue === (string)$condition['equals']; } function builderEscape($value): string { return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); } /* |-------------------------------------------------------------------------- | DELETE BUILDER RECORD |-------------------------------------------------------------------------- | | Permanently removes one authored record after: | | 1. verifying it belongs to the current game; | 2. checking configured dependencies; | 3. allowing the database to reject any other protected references. | | This is intentionally generic. Builder types describe their own | dependency rules in config/builder-types.php. | */ function deleteBuilderRecord( PDO $pdo, array $definition, int $gameId, int $id ): array { $deleteDefinition = $definition['delete'] ?? []; if (empty($deleteDefinition['enabled'])) { return [ 'deleted' => false, 'error' => 'Deletion is not enabled for this type.' ]; } if ($id <= 0) { return [ 'deleted' => false, 'error' => 'A valid record ID is required.' ]; } $table = $definition['table']; $listField = $definition['list_field']; $singular = $definition['singular'] ?? 'record'; $scopeQuery = buildScopeQuery($definition); /* * Verify the record belongs to this game before doing anything else. */ $sql = " SELECT `$table`.`id`, `$table`.`$listField` AS record_label FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($sql); $stmt->execute([ $id, $gameId ]); $record = $stmt->fetch(); if (!$record) { return [ 'deleted' => false, 'error' => $singular . ' not found.' ]; } $recordLabel = (string)($record['record_label'] ?? ''); /* * Check dependency rules declared by this builder type. */ foreach ( ($deleteDefinition['dependencies'] ?? []) as $dependency ) { $dependencyTable = $dependency['table'] ?? ''; $foreignKey = $dependency['foreign_key'] ?? ''; $dependencyLabel = $dependency['label'] ?? 'related records'; if ( !preg_match('/^[A-Za-z0-9_]+$/', $dependencyTable) || !preg_match('/^[A-Za-z0-9_]+$/', $foreignKey) ) { return [ 'deleted' => false, 'error' => 'The delete dependency configuration for this ' . $singular . ' is invalid.' ]; } $dependencySql = " SELECT COUNT(*) FROM `$dependencyTable` WHERE `$foreignKey` = ? "; $dependencyStmt = $pdo->prepare($dependencySql); $dependencyStmt->execute([ $id ]); $dependencyCount = (int)$dependencyStmt->fetchColumn(); if ($dependencyCount > 0) { $namePart = $recordLabel !== '' ? ' "' . $recordLabel . '"' : ''; return [ 'deleted' => false, 'error' => 'Cannot delete ' . $singular . $namePart . '. It still has ' . $dependencyCount . ' ' . $dependencyLabel . '. Move or delete those first.' ]; } } /* * Delete only after all explicit dependency checks have passed. * * A database foreign-key constraint may still block deletion if a * dependency has not yet been described in builder-types.php. In that * case, report the failure rather than crashing the builder. */ try { $deleteSql = " DELETE FROM `$table` WHERE id = ? LIMIT 1 "; $deleteStmt = $pdo->prepare($deleteSql); $deleteStmt->execute([ $id ]); } catch (PDOException $e) { return [ 'deleted' => false, 'error' => 'This ' . $singular . ' could not be deleted because another record still ' . 'references it.' ]; } if ($deleteStmt->rowCount() !== 1) { return [ 'deleted' => false, 'error' => 'The ' . $singular . ' was not deleted.' ]; } return [ 'deleted' => true, 'error' => '' ]; } ================================================================================ FILE: _v8_backups/20260922-153106/includes/builder/save-record.php ================================================================================ 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { $configToSave['traits'] = $traits; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $values[] = $record[$fieldName]; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: _v9_1_backups/20260922-154455/includes/builder/functions.php ================================================================================ prepare($sql); $stmt->execute([$gameId]); return $stmt->fetchAll(); } function buildScopeQuery(array $definition): array { $table = $definition['table']; $scope = $definition['scope']; if ($scope['type'] === 'game') { return [ 'from' => "`$table`", 'where' => "`$table`.`{$scope['field']}` = ?", ]; } $scopeTable = $scope['scope_table']; $scopeField = $scope['field']; $scopeKey = $scope['scope_key']; $gameField = $scope['game_field']; return [ 'from' => " `$table` INNER JOIN `$scopeTable` ON `$table`.`$scopeField` = `$scopeTable`.`$scopeKey` ", 'where' => "`$scopeTable`.`$gameField` = ?", ]; } function getRelatedFieldValue( PDO $pdo, array $condition, $relationshipValue ) { if ($relationshipValue === '' || $relationshipValue === null) { return null; } $table = $condition['source_table']; $sourceKey = $condition['source_key']; $sourceField = $condition['source_field']; $sql = " SELECT `$sourceField` FROM `$table` WHERE `$sourceKey` = ? LIMIT 1 "; $stmt = $pdo->prepare($sql); $stmt->execute([$relationshipValue]); $value = $stmt->fetchColumn(); return $value === false ? null : $value; } function configFieldApplies( PDO $pdo, array $field, array $record ): bool { if (empty($field['condition'])) { return true; } $condition = $field['condition']; $relationshipName = $condition['relationship'] ?? null; if (!$relationshipName) { return false; } $relationshipValue = $record[$relationshipName] ?? ''; $sourceValue = getRelatedFieldValue( $pdo, $condition, $relationshipValue ); return (string)$sourceValue === (string)$condition['equals']; } function builderEscape($value): string { return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); } /* |-------------------------------------------------------------------------- | DELETE BUILDER RECORD |-------------------------------------------------------------------------- | | Permanently removes one authored record after: | | 1. verifying it belongs to the current game; | 2. checking configured dependencies; | 3. allowing the database to reject any other protected references. | | This is intentionally generic. Builder types describe their own | dependency rules in config/builder-types.php. | */ function deleteBuilderRecord( PDO $pdo, array $definition, int $gameId, int $id ): array { $deleteDefinition = $definition['delete'] ?? []; if (empty($deleteDefinition['enabled'])) { return [ 'deleted' => false, 'error' => 'Deletion is not enabled for this type.' ]; } if ($id <= 0) { return [ 'deleted' => false, 'error' => 'A valid record ID is required.' ]; } $table = $definition['table']; $listField = $definition['list_field']; $singular = $definition['singular'] ?? 'record'; $scopeQuery = buildScopeQuery($definition); /* * Verify the record belongs to this game before doing anything else. */ $sql = " SELECT `$table`.`id`, `$table`.`$listField` AS record_label, `$table`.`config` AS record_config FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($sql); $stmt->execute([ $id, $gameId ]); $record = $stmt->fetch(); if (!$record) { return [ 'deleted' => false, 'error' => $singular . ' not found.' ]; } $recordLabel = (string)($record['record_label'] ?? ''); /* * Check dependency rules declared by this builder type. */ foreach ( ($deleteDefinition['dependencies'] ?? []) as $dependency ) { $dependencyTable = $dependency['table'] ?? ''; $foreignKey = $dependency['foreign_key'] ?? ''; $dependencyLabel = $dependency['label'] ?? 'related records'; if ( !preg_match('/^[A-Za-z0-9_]+$/', $dependencyTable) || !preg_match('/^[A-Za-z0-9_]+$/', $foreignKey) ) { return [ 'deleted' => false, 'error' => 'The delete dependency configuration for this ' . $singular . ' is invalid.' ]; } $dependencySql = " SELECT COUNT(*) FROM `$dependencyTable` WHERE `$foreignKey` = ? "; $dependencyStmt = $pdo->prepare($dependencySql); $dependencyStmt->execute([ $id ]); $dependencyCount = (int)$dependencyStmt->fetchColumn(); if ($dependencyCount > 0) { $namePart = $recordLabel !== '' ? ' "' . $recordLabel . '"' : ''; return [ 'deleted' => false, 'error' => 'Cannot delete ' . $singular . $namePart . '. It still has ' . $dependencyCount . ' ' . $dependencyLabel . '. Move or delete those first.' ]; } } /* * Delete only after all explicit dependency checks have passed. * * A database foreign-key constraint may still block deletion if a * dependency has not yet been described in builder-types.php. In that * case, report the failure rather than crashing the builder. */ try { $deleteSql = " DELETE FROM `$table` WHERE id = ? LIMIT 1 "; $deleteStmt = $pdo->prepare($deleteSql); $deleteStmt->execute([ $id ]); } catch (PDOException $e) { return [ 'deleted' => false, 'error' => 'This ' . $singular . ' could not be deleted because another record still ' . 'references it.' ]; } if ($deleteStmt->rowCount() !== 1) { return [ 'deleted' => false, 'error' => 'The ' . $singular . ' was not deleted.' ]; } /* * WAYFARER MEDIA LINK CLEANUP * * Media is game-level and reusable. Deleting this authored object * removes only its media associations, never the underlying asset. */ $mediaLinkDelete = $pdo->prepare( " DELETE FROM wf_media_links WHERE object_type = ? AND object_id = ? " ); $mediaLinkDelete->execute([ $table, $id ]); return [ 'deleted' => true, 'error' => '' ]; } /* |-------------------------------------------------------------------------- | GENERIC BUILDER MEDIA |-------------------------------------------------------------------------- | | Every authored object can optionally have: | | - one image | - one audio file | | The files live under /uploads/media/ and metadata is stored in config.media. | */ function getBuilderMediaDefinition(): array { return [ 'image' => [ 'label' => 'Image', 'accept' => '.jpg,.jpeg,.png,.gif', 'allowed_mime' => [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif' ], 'max_bytes' => 12 * 1024 * 1024, 'help' => 'Optional. JPG, PNG, or GIF. Uploading a new image replaces the current image.' ], 'audio' => [ 'label' => 'Audio', 'accept' => '.mp3,audio/mpeg', 'allowed_mime' => [ 'audio/mpeg' => 'mp3', 'audio/mp3' => 'mp3', 'audio/x-mpeg' => 'mp3' ], 'max_bytes' => 30 * 1024 * 1024, 'help' => 'Optional. MP3 only. Uploading a new audio file replaces the current audio.' ] ]; } function builderMediaAdminUrl(string $relativePath): string { return '../' . ltrim( $relativePath, '/' ); } function builderMediaUploadErrorMessage( int $error, string $label ): string { if ( $error === UPLOAD_ERR_INI_SIZE || $error === UPLOAD_ERR_FORM_SIZE ) { return $label . ' is larger than the server upload limit.'; } if ($error === UPLOAD_ERR_PARTIAL) { return $label . ' was only partially uploaded. Please try again.'; } if ($error === UPLOAD_ERR_NO_TMP_DIR) { return 'The server has no temporary upload directory.'; } if ($error === UPLOAD_ERR_CANT_WRITE) { return 'The server could not write the uploaded file.'; } if ($error === UPLOAD_ERR_EXTENSION) { return $label . ' was blocked by a server extension.'; } return 'The ' . strtolower($label) . ' upload failed.'; } function prepareBuilderMediaChanges( int $gameId, string $table, array $files, array $post, array $existingMedia ): array { $definition = getBuilderMediaDefinition(); $pending = []; $updatedMedia = is_array($existingMedia) ? $existingMedia : []; $cleanupPaths = []; $newPaths = []; /* * Validate every upload before moving any file. */ foreach ($definition as $kind => $mediaDefinition) { $inputName = 'media_' . $kind; $file = $files[$inputName] ?? null; $removeRequested = !empty( $post['remove_media'][$kind] ); $hasUpload = is_array($file) && isset($file['error']) && (int)$file['error'] !== UPLOAD_ERR_NO_FILE; if (!$hasUpload) { if ( $removeRequested && !empty($updatedMedia[$kind]['path']) ) { $cleanupPaths[] = $updatedMedia[$kind]['path']; unset( $updatedMedia[$kind] ); } continue; } $uploadError = (int)$file['error']; if ($uploadError !== UPLOAD_ERR_OK) { return [ 'media' => $existingMedia, 'cleanup_paths' => [], 'new_paths' => [], 'error' => builderMediaUploadErrorMessage( $uploadError, $mediaDefinition['label'] ) ]; } $size = (int)($file['size'] ?? 0); if ( $size <= 0 || $size > (int)$mediaDefinition['max_bytes'] ) { $maxMb = round( $mediaDefinition['max_bytes'] / 1024 / 1024 ); return [ 'media' => $existingMedia, 'cleanup_paths' => [], 'new_paths' => [], 'error' => $mediaDefinition['label'] . ' must be no larger than ' . $maxMb . ' MB.' ]; } $tmpName = (string)($file['tmp_name'] ?? ''); if ( $tmpName === '' || !is_uploaded_file($tmpName) ) { return [ 'media' => $existingMedia, 'cleanup_paths' => [], 'new_paths' => [], 'error' => 'The uploaded ' . strtolower($mediaDefinition['label']) . ' could not be verified.' ]; } $finfo = finfo_open( FILEINFO_MIME_TYPE ); $mime = $finfo ? finfo_file( $finfo, $tmpName ) : false; if ($finfo) { finfo_close($finfo); } $allowedMime = $mediaDefinition['allowed_mime']; if ( !$mime || !isset($allowedMime[$mime]) ) { return [ 'media' => $existingMedia, 'cleanup_paths' => [], 'new_paths' => [], 'error' => 'Unsupported ' . strtolower($mediaDefinition['label']) . ' file type.' ]; } $pending[$kind] = [ 'tmp_name' => $tmpName, 'extension' => $allowedMime[$mime], 'mime' => $mime, 'size' => $size, 'original_name' => basename( (string)($file['name'] ?? '') ) ]; } if (!$pending) { return [ 'media' => $updatedMedia, 'cleanup_paths' => $cleanupPaths, 'new_paths' => [], 'error' => '' ]; } $safeTable = preg_replace( '/[^A-Za-z0-9_-]/', '_', $table ); $relativeDirectory = 'uploads/media/game_' . $gameId . '/' . $safeTable; $absoluteDirectory = dirname(__DIR__, 2) . '/' . $relativeDirectory; if ( !is_dir($absoluteDirectory) && !mkdir( $absoluteDirectory, 0755, true ) ) { return [ 'media' => $existingMedia, 'cleanup_paths' => [], 'new_paths' => [], 'error' => 'Wayfarer could not create the media upload directory.' ]; } /* * Move validated uploads. If one move fails, remove any new files * already moved during this request. */ foreach ($pending as $kind => $upload) { try { $randomName = bin2hex( random_bytes(16) ); } catch (Throwable $e) { foreach ($newPaths as $newPath) { deleteBuilderMediaFile( $newPath ); } return [ 'media' => $existingMedia, 'cleanup_paths' => [], 'new_paths' => [], 'error' => 'Wayfarer could not generate a secure media filename.' ]; } $fileName = $randomName . '.' . $upload['extension']; $relativePath = $relativeDirectory . '/' . $fileName; $absolutePath = dirname(__DIR__, 2) . '/' . $relativePath; if ( !move_uploaded_file( $upload['tmp_name'], $absolutePath ) ) { foreach ($newPaths as $newPath) { deleteBuilderMediaFile( $newPath ); } return [ 'media' => $existingMedia, 'cleanup_paths' => [], 'new_paths' => [], 'error' => 'Wayfarer could not save the uploaded ' . $kind . '.' ]; } $newPaths[] = $relativePath; if ( !empty($updatedMedia[$kind]['path']) ) { $cleanupPaths[] = $updatedMedia[$kind]['path']; } $updatedMedia[$kind] = [ 'path' => $relativePath, 'mime' => $upload['mime'], 'original_name' => $upload['original_name'], 'size' => $upload['size'] ]; } return [ 'media' => $updatedMedia, 'cleanup_paths' => array_values( array_unique( $cleanupPaths ) ), 'new_paths' => $newPaths, 'error' => '' ]; } function deleteBuilderMediaFile(string $relativePath): void { $relativePath = ltrim( $relativePath, '/' ); /* * Never allow this helper to unlink anything outside Wayfarer's * own media directory. */ if ( strpos( $relativePath, 'uploads/media/' ) !== 0 || strpos( $relativePath, '..' ) !== false ) { return; } $absolutePath = dirname(__DIR__, 2) . '/' . $relativePath; if (is_file($absolutePath)) { @unlink($absolutePath); } } function deleteBuilderMediaFiles(array $paths): void { foreach ( array_unique($paths) as $path ) { if (is_string($path)) { deleteBuilderMediaFile( $path ); } } } /* |-------------------------------------------------------------------------- | WAYFARER MEDIA LIBRARY |-------------------------------------------------------------------------- | | Uploaded files are game-level reusable assets stored in wf_media. | wf_media_links associates those assets with authored objects. | */ function getBuilderMediaLibraryDefinition(): array { return [ 'image' => [ 'label' => 'Image', 'directory' => 'images', 'accept' => '.jpg,.jpeg,.png,.gif', 'app_max_bytes' => 10 * 1024 * 1024, 'allowed_mime' => [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif' ], 'help' => 'Optional. JPG, PNG, or GIF.' ], 'audio' => [ 'label' => 'Audio', 'directory' => 'audio', 'accept' => '.mp3,audio/mpeg', 'app_max_bytes' => 30 * 1024 * 1024, 'allowed_mime' => [ 'audio/mpeg' => 'mp3', 'audio/mp3' => 'mp3', 'audio/x-mpeg' => 'mp3', 'audio/mpeg3' => 'mp3', 'audio/x-mpeg-3' => 'mp3' ], 'help' => 'Optional. MP3 only.' ] ]; } function builderIniSizeToBytes($value): int { if ($value === false || $value === null) { return 0; } $value = trim( (string)$value ); if ( $value === '' || $value === '-1' ) { return 0; } $last = strtolower( substr( $value, -1 ) ); $number = (float)$value; if ($last === 'g') { $number *= 1024; $number *= 1024; $number *= 1024; } elseif ($last === 'm') { $number *= 1024; $number *= 1024; } elseif ($last === 'k') { $number *= 1024; } return (int)floor($number); } function getBuilderEffectiveUploadLimit( int $applicationLimit ): int { $limits = [ $applicationLimit ]; $uploadLimit = builderIniSizeToBytes( ini_get( 'upload_max_filesize' ) ); $postLimit = builderIniSizeToBytes( ini_get( 'post_max_size' ) ); if ($uploadLimit > 0) { $limits[] = $uploadLimit; } if ($postLimit > 0) { $limits[] = $postLimit; } return min( $limits ); } function formatBuilderBytes( int $bytes ): string { if ($bytes >= 1024 * 1024 * 1024) { return rtrim( rtrim( number_format( $bytes / 1024 / 1024 / 1024, 1 ), '0' ), '.' ) . ' GB'; } if ($bytes >= 1024 * 1024) { return rtrim( rtrim( number_format( $bytes / 1024 / 1024, 1 ), '0' ), '.' ) . ' MB'; } if ($bytes >= 1024) { return rtrim( rtrim( number_format( $bytes / 1024, 1 ), '0' ), '.' ) . ' KB'; } return $bytes . ' bytes'; } function builderMediaPublicUrl( string $relativePath ): string { return '../' . ltrim( $relativePath, '/' ); } function getBuilderGameMediaAssets( PDO $pdo, int $gameId, string $mediaType ): array { $stmt = $pdo->prepare( " SELECT id, name, original_filename, relative_path, mime_type, file_size FROM wf_media WHERE game_id = ? AND media_type = ? ORDER BY name, original_filename, id " ); $stmt->execute([ $gameId, $mediaType ]); return $stmt->fetchAll(); } function getBuilderMediaLinksForObject( PDO $pdo, int $gameId, string $objectType, int $objectId ): array { if ($objectId <= 0) { return []; } $stmt = $pdo->prepare( " SELECT l.slot, m.id, m.name, m.media_type, m.original_filename, m.relative_path, m.mime_type, m.file_size FROM wf_media_links l INNER JOIN wf_media m ON m.id = l.media_id WHERE l.object_type = ? AND l.object_id = ? AND m.game_id = ? " ); $stmt->execute([ $objectType, $objectId, $gameId ]); $result = []; foreach ($stmt->fetchAll() as $row) { $result[ $row['slot'] ] = $row; } return $result; } function builderMediaUploadErrorMessage( int $error, string $label ): string { if ( $error === UPLOAD_ERR_INI_SIZE || $error === UPLOAD_ERR_FORM_SIZE ) { return $label . ' exceeds the server upload limit.'; } if ($error === UPLOAD_ERR_PARTIAL) { return $label . ' was only partially uploaded. Please try again.'; } if ($error === UPLOAD_ERR_NO_TMP_DIR) { return 'The server has no temporary upload directory.'; } if ($error === UPLOAD_ERR_CANT_WRITE) { return 'The server could not write the uploaded file.'; } if ($error === UPLOAD_ERR_EXTENSION) { return $label . ' was blocked by a server extension.'; } return $label . ' upload failed.'; } function validateBuilderMediaUpload( array $file, string $slot, array $slotDefinition ): array { $label = $slotDefinition['label']; $uploadError = (int)( $file['error'] ?? UPLOAD_ERR_NO_FILE ); if ($uploadError === UPLOAD_ERR_NO_FILE) { return [ 'valid' => false, 'error' => 'Choose a file to upload for ' . strtolower($label) . '.' ]; } if ($uploadError !== UPLOAD_ERR_OK) { return [ 'valid' => false, 'error' => builderMediaUploadErrorMessage( $uploadError, $label ) ]; } $tmpName = (string)( $file['tmp_name'] ?? '' ); if ( $tmpName === '' || !is_uploaded_file( $tmpName ) ) { return [ 'valid' => false, 'error' => 'The uploaded ' . strtolower($label) . ' could not be verified.' ]; } $size = (int)( $file['size'] ?? 0 ); $effectiveLimit = getBuilderEffectiveUploadLimit( (int)$slotDefinition['app_max_bytes'] ); if ( $size <= 0 || $size > $effectiveLimit ) { return [ 'valid' => false, 'error' => $label . ' must be no larger than ' . formatBuilderBytes( $effectiveLimit ) . '.' ]; } $originalName = basename( (string)( $file['name'] ?? '' ) ); $originalExtension = strtolower( pathinfo( $originalName, PATHINFO_EXTENSION ) ); $finfo = finfo_open( FILEINFO_MIME_TYPE ); $mime = $finfo ? finfo_file( $finfo, $tmpName ) : false; if ($finfo) { finfo_close( $finfo ); } $allowedMime = $slotDefinition['allowed_mime']; if ( !$mime || !isset( $allowedMime[$mime] ) ) { return [ 'valid' => false, 'error' => 'Unsupported ' . strtolower($label) . ' file type.' ]; } $normalizedExtension = $allowedMime[$mime]; if ( $slot === 'image' && !in_array( $originalExtension, [ 'jpg', 'jpeg', 'png', 'gif' ], true ) ) { return [ 'valid' => false, 'error' => 'Image filename must end in JPG, JPEG, PNG, or GIF.' ]; } if ( $slot === 'audio' && $originalExtension !== 'mp3' ) { return [ 'valid' => false, 'error' => 'Audio filename must end in MP3.' ]; } if ($slot === 'image') { $imageInfo = @getimagesize( $tmpName ); if ($imageInfo === false) { return [ 'valid' => false, 'error' => 'The uploaded image is not a valid raster image.' ]; } } return [ 'valid' => true, 'error' => '', 'tmp_name' => $tmpName, 'size' => $size, 'mime' => $mime, 'extension' => $normalizedExtension, 'original_filename' => $originalName ]; } function builderMediaDefaultName( string $originalFilename ): string { $name = pathinfo( $originalFilename, PATHINFO_FILENAME ); $name = preg_replace( '/[_-]+/', ' ', $name ); $name = trim( preg_replace( '/\s+/', ' ', $name ) ); if ($name === '') { return 'Untitled Media'; } return $name; } function setBuilderMediaLink( PDO $pdo, int $mediaId, string $objectType, int $objectId, string $slot ): void { $stmt = $pdo->prepare( " INSERT INTO wf_media_links ( media_id, object_type, object_id, slot ) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE media_id = VALUES(media_id) " ); $stmt->execute([ $mediaId, $objectType, $objectId, $slot ]); } function removeBuilderMediaLink( PDO $pdo, string $objectType, int $objectId, string $slot ): void { $stmt = $pdo->prepare( " DELETE FROM wf_media_links WHERE object_type = ? AND object_id = ? AND slot = ? " ); $stmt->execute([ $objectType, $objectId, $slot ]); } function applyBuilderMediaSelections( PDO $pdo, int $gameId, string $objectType, int $objectId, array $post, array $files ): array { if ($objectId <= 0) { return [ 'saved' => false, 'error' => 'Media cannot be attached until the authored object has an ID.' ]; } $definition = getBuilderMediaLibraryDefinition(); $current = getBuilderMediaLinksForObject( $pdo, $gameId, $objectType, $objectId ); $plan = []; /* * First validate every requested operation. Nothing changes yet. */ foreach ($definition as $slot => $slotDefinition) { $defaultAction = isset($current[$slot]) ? 'keep' : 'none'; $action = (string)( $post['media_action'][$slot] ?? $defaultAction ); if ( !in_array( $action, [ 'keep', 'none', 'existing', 'upload' ], true ) ) { return [ 'saved' => false, 'error' => 'Invalid ' . strtolower($slotDefinition['label']) . ' action.' ]; } if ($action === 'keep') { $plan[$slot] = [ 'action' => 'keep' ]; continue; } if ($action === 'none') { $plan[$slot] = [ 'action' => 'none' ]; continue; } if ($action === 'existing') { $mediaId = (int)( $post['media_existing'][$slot] ?? 0 ); if ($mediaId <= 0) { return [ 'saved' => false, 'error' => 'Choose an existing ' . strtolower($slotDefinition['label']) . '.' ]; } $stmt = $pdo->prepare( " SELECT id FROM wf_media WHERE id = ? AND game_id = ? AND media_type = ? LIMIT 1 " ); $stmt->execute([ $mediaId, $gameId, $slot ]); if (!$stmt->fetchColumn()) { return [ 'saved' => false, 'error' => 'The selected existing ' . strtolower($slotDefinition['label']) . ' is not available to this game.' ]; } $plan[$slot] = [ 'action' => 'existing', 'media_id' => $mediaId ]; continue; } $fileKey = 'media_' . $slot; $validation = validateBuilderMediaUpload( $files[$fileKey] ?? [], $slot, $slotDefinition ); if (!$validation['valid']) { return [ 'saved' => false, 'error' => $validation['error'] ]; } $requestedName = trim( (string)( $post['media_name'][$slot] ?? '' ) ); if ($requestedName === '') { $requestedName = builderMediaDefaultName( $validation['original_filename'] ); } if ( mb_strlen( $requestedName ) > 255 ) { return [ 'saved' => false, 'error' => $slotDefinition['label'] . ' library name must be 255 characters or fewer.' ]; } $plan[$slot] = array_merge( [ 'action' => 'upload', 'name' => $requestedName ], $validation ); } $newPhysicalPaths = []; try { $pdo->beginTransaction(); foreach ($plan as $slot => $operation) { if ($operation['action'] === 'keep') { continue; } if ($operation['action'] === 'none') { removeBuilderMediaLink( $pdo, $objectType, $objectId, $slot ); continue; } if ($operation['action'] === 'existing') { setBuilderMediaLink( $pdo, (int)$operation['media_id'], $objectType, $objectId, $slot ); continue; } $slotDefinition = $definition[$slot]; $relativeDirectory = 'uploads/media/game_' . $gameId . '/' . $slotDefinition['directory']; $absoluteDirectory = dirname(__DIR__, 2) . '/' . $relativeDirectory; if ( !is_dir( $absoluteDirectory ) && !mkdir( $absoluteDirectory, 0755, true ) ) { throw new RuntimeException( 'Wayfarer could not create the ' . strtolower($slotDefinition['label']) . ' media directory.' ); } $storedFilename = bin2hex( random_bytes(16) ) . '.' . $operation['extension']; $relativePath = $relativeDirectory . '/' . $storedFilename; $absolutePath = dirname(__DIR__, 2) . '/' . $relativePath; if ( !move_uploaded_file( $operation['tmp_name'], $absolutePath ) ) { throw new RuntimeException( 'Wayfarer could not save the uploaded ' . strtolower($slotDefinition['label']) . '.' ); } $newPhysicalPaths[] = $absolutePath; $insert = $pdo->prepare( " INSERT INTO wf_media ( game_id, media_type, name, original_filename, stored_filename, relative_path, mime_type, file_size, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) " ); $insert->execute([ $gameId, $slot, $operation['name'], $operation['original_filename'], $storedFilename, $relativePath, $operation['mime'], $operation['size'] ]); $mediaId = (int)$pdo->lastInsertId(); setBuilderMediaLink( $pdo, $mediaId, $objectType, $objectId, $slot ); } $pdo->commit(); } catch (Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } foreach ($newPhysicalPaths as $path) { if (is_file($path)) { @unlink($path); } } return [ 'saved' => false, 'error' => $e->getMessage() ]; } return [ 'saved' => true, 'error' => '' ]; } ================================================================================ FILE: _v9_backups/20260922-154240/admin/builder-refactored.php ================================================================================ prepare(" SELECT id, name FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; // ================================================================ // SAVE / DELETE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $_FILES, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder


+ New




← Games

================================================================================ FILE: _v9_backups/20260922-154240/assets/js/builder.js ================================================================================ const wayfarerConditionData = window.wayfarerConditionData || {}; function updateConditionalConfigFields() { document .querySelectorAll('[data-config-field]') .forEach(function (container) { const relationshipName = container.dataset.conditionRelationship; const requiredValue = container.dataset.conditionEquals; if (!relationshipName) { container.style.display = ''; return; } const relationshipSelect = document.querySelector( '[data-relationship="' + relationshipName + '"]' ); if (!relationshipSelect) { container.style.display = 'none'; return; } const selectedId = relationshipSelect.value; const actualValue = wayfarerConditionData[relationshipName]?.[selectedId]; container.style.display = actualValue === requiredValue ? '' : 'none'; }); } document .querySelectorAll('[data-relationship]') .forEach(function (select) { select.addEventListener( 'change', updateConditionalConfigFields ); }); // ================================================================ // SYSTEM TRAIT GROUPS // ================================================================ function updateSystemTraitGroups(container) { const selectorField = container.dataset.selectorField; if (!selectorField) { return; } const selector = document.querySelector( '[name="' + selectorField + '"]' ); if (!selector) { return; } const selectedGroup = selector.value; container .querySelectorAll('[data-system-trait-group]') .forEach(function (group) { const active = group.dataset.systemTraitGroup === selectedGroup; group.style.display = active ? '' : 'none'; group .querySelectorAll('input, select, textarea') .forEach(function (input) { input.disabled = !active; }); }); } document .querySelectorAll('[data-system-trait-settings]') .forEach(function (container) { const selectorField = container.dataset.selectorField; const selector = selectorField ? document.querySelector( '[name="' + selectorField + '"]' ) : null; if (selector) { selector.addEventListener( 'change', function () { updateSystemTraitGroups(container); } ); } updateSystemTraitGroups(container); }); // ================================================================ // COLLECTION SORTING // ================================================================ document .querySelectorAll('[data-sort-column]') .forEach(function (button) { button.addEventListener('click', function () { const table = button.closest('[data-collection-table]'); if (!table) { return; } const tbody = table.querySelector('tbody'); const columnName = button.dataset.sortColumn; const sortType = button.dataset.sortType || 'text'; const currentDirection = button.dataset.sortDirection || 'none'; const nextDirection = currentDirection === 'asc' ? 'desc' : 'asc'; table .querySelectorAll('[data-sort-column]') .forEach(function (otherButton) { otherButton.dataset.sortDirection = 'none'; }); button.dataset.sortDirection = nextDirection; const rows = Array.from(tbody.querySelectorAll('tr')); rows.sort(function (a, b) { const aCell = a.querySelector( '[data-column="' + columnName + '"]' ); const bCell = b.querySelector( '[data-column="' + columnName + '"]' ); const aValue = aCell?.dataset.sortValue ?? ''; const bValue = bCell?.dataset.sortValue ?? ''; let comparison = 0; if (sortType === 'number') { const aNumber = aValue === '' ? Number.POSITIVE_INFINITY : Number(aValue); const bNumber = bValue === '' ? Number.POSITIVE_INFINITY : Number(bValue); comparison = aNumber - bNumber; } else { comparison = aValue.localeCompare( bValue, undefined, { numeric: true, sensitivity: 'base' } ); } return nextDirection === 'asc' ? comparison : -comparison; }); rows.forEach(function (row) { tbody.appendChild(row); }); }); }); updateConditionalConfigFields(); ================================================================================ FILE: _v9_backups/20260922-154240/includes/builder/editor.php ================================================================================ $groupValues) { if (!is_array($groupValues)) { continue; } foreach ($groupValues as $traitName => $traitValue) { if (!isset($storedSystemTraitValues[$groupName][$traitName])) { $storedSystemTraitValues[$groupName][$traitName] = $traitValue; } } } } } $renderSystemTraits = function () use ( $record, $fields, $systemTraitsSelector, $systemTraitGroups, $storedSystemTraitValues, $storedSystemTraitMaxValues ) { if (!$systemTraitsSelector || empty($systemTraitGroups)) { return; } $selectedGroup = $record[$systemTraitsSelector] ?? ($fields[$systemTraitsSelector]['default'] ?? ''); ?>
$groupDefinition): ?>
>

$traitDefinition): ?>

$relationship): ?> $field): ?>

Media

Optional media associated with this authored object. Uploading a new file replaces the current file.

$mediaDefinition): ?>


Current file:
$field): ?>

Traits

Traits are additional values associated with this that can change during play.


Danger Zone

Permanently delete this . This cannot be undone.

================================================================================ FILE: _v9_backups/20260922-154240/includes/builder/functions.php ================================================================================ prepare($sql); $stmt->execute([$gameId]); return $stmt->fetchAll(); } function buildScopeQuery(array $definition): array { $table = $definition['table']; $scope = $definition['scope']; if ($scope['type'] === 'game') { return [ 'from' => "`$table`", 'where' => "`$table`.`{$scope['field']}` = ?", ]; } $scopeTable = $scope['scope_table']; $scopeField = $scope['field']; $scopeKey = $scope['scope_key']; $gameField = $scope['game_field']; return [ 'from' => " `$table` INNER JOIN `$scopeTable` ON `$table`.`$scopeField` = `$scopeTable`.`$scopeKey` ", 'where' => "`$scopeTable`.`$gameField` = ?", ]; } function getRelatedFieldValue( PDO $pdo, array $condition, $relationshipValue ) { if ($relationshipValue === '' || $relationshipValue === null) { return null; } $table = $condition['source_table']; $sourceKey = $condition['source_key']; $sourceField = $condition['source_field']; $sql = " SELECT `$sourceField` FROM `$table` WHERE `$sourceKey` = ? LIMIT 1 "; $stmt = $pdo->prepare($sql); $stmt->execute([$relationshipValue]); $value = $stmt->fetchColumn(); return $value === false ? null : $value; } function configFieldApplies( PDO $pdo, array $field, array $record ): bool { if (empty($field['condition'])) { return true; } $condition = $field['condition']; $relationshipName = $condition['relationship'] ?? null; if (!$relationshipName) { return false; } $relationshipValue = $record[$relationshipName] ?? ''; $sourceValue = getRelatedFieldValue( $pdo, $condition, $relationshipValue ); return (string)$sourceValue === (string)$condition['equals']; } function builderEscape($value): string { return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); } /* |-------------------------------------------------------------------------- | DELETE BUILDER RECORD |-------------------------------------------------------------------------- | | Permanently removes one authored record after: | | 1. verifying it belongs to the current game; | 2. checking configured dependencies; | 3. allowing the database to reject any other protected references. | | This is intentionally generic. Builder types describe their own | dependency rules in config/builder-types.php. | */ function deleteBuilderRecord( PDO $pdo, array $definition, int $gameId, int $id ): array { $deleteDefinition = $definition['delete'] ?? []; if (empty($deleteDefinition['enabled'])) { return [ 'deleted' => false, 'error' => 'Deletion is not enabled for this type.' ]; } if ($id <= 0) { return [ 'deleted' => false, 'error' => 'A valid record ID is required.' ]; } $table = $definition['table']; $listField = $definition['list_field']; $singular = $definition['singular'] ?? 'record'; $scopeQuery = buildScopeQuery($definition); /* * Verify the record belongs to this game before doing anything else. */ $sql = " SELECT `$table`.`id`, `$table`.`$listField` AS record_label, `$table`.`config` AS record_config FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($sql); $stmt->execute([ $id, $gameId ]); $record = $stmt->fetch(); if (!$record) { return [ 'deleted' => false, 'error' => $singular . ' not found.' ]; } $recordLabel = (string)($record['record_label'] ?? ''); /* * Check dependency rules declared by this builder type. */ foreach ( ($deleteDefinition['dependencies'] ?? []) as $dependency ) { $dependencyTable = $dependency['table'] ?? ''; $foreignKey = $dependency['foreign_key'] ?? ''; $dependencyLabel = $dependency['label'] ?? 'related records'; if ( !preg_match('/^[A-Za-z0-9_]+$/', $dependencyTable) || !preg_match('/^[A-Za-z0-9_]+$/', $foreignKey) ) { return [ 'deleted' => false, 'error' => 'The delete dependency configuration for this ' . $singular . ' is invalid.' ]; } $dependencySql = " SELECT COUNT(*) FROM `$dependencyTable` WHERE `$foreignKey` = ? "; $dependencyStmt = $pdo->prepare($dependencySql); $dependencyStmt->execute([ $id ]); $dependencyCount = (int)$dependencyStmt->fetchColumn(); if ($dependencyCount > 0) { $namePart = $recordLabel !== '' ? ' "' . $recordLabel . '"' : ''; return [ 'deleted' => false, 'error' => 'Cannot delete ' . $singular . $namePart . '. It still has ' . $dependencyCount . ' ' . $dependencyLabel . '. Move or delete those first.' ]; } } /* * Delete only after all explicit dependency checks have passed. * * A database foreign-key constraint may still block deletion if a * dependency has not yet been described in builder-types.php. In that * case, report the failure rather than crashing the builder. */ try { $deleteSql = " DELETE FROM `$table` WHERE id = ? LIMIT 1 "; $deleteStmt = $pdo->prepare($deleteSql); $deleteStmt->execute([ $id ]); } catch (PDOException $e) { return [ 'deleted' => false, 'error' => 'This ' . $singular . ' could not be deleted because another record still ' . 'references it.' ]; } if ($deleteStmt->rowCount() !== 1) { return [ 'deleted' => false, 'error' => 'The ' . $singular . ' was not deleted.' ]; } /* * WAYFARER DELETE MEDIA CLEANUP * * The database row is already gone, so media cleanup cannot prevent * deletion if an individual file happens to be missing. */ $deletedConfig = !empty($record['record_config']) ? json_decode( $record['record_config'], true ) : []; if ( is_array($deletedConfig) && !empty($deletedConfig['media']) && is_array($deletedConfig['media']) ) { $deletedMediaPaths = []; foreach ($deletedConfig['media'] as $mediaItem) { if ( is_array($mediaItem) && !empty($mediaItem['path']) ) { $deletedMediaPaths[] = $mediaItem['path']; } } deleteBuilderMediaFiles( $deletedMediaPaths ); } return [ 'deleted' => true, 'error' => '' ]; } /* |-------------------------------------------------------------------------- | GENERIC BUILDER MEDIA |-------------------------------------------------------------------------- | | Every authored object can optionally have: | | - one image | - one audio file | | The files live under /uploads/media/ and metadata is stored in config.media. | */ function getBuilderMediaDefinition(): array { return [ 'image' => [ 'label' => 'Image', 'accept' => '.jpg,.jpeg,.png,.gif', 'allowed_mime' => [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif' ], 'max_bytes' => 12 * 1024 * 1024, 'help' => 'Optional. JPG, PNG, or GIF. Uploading a new image replaces the current image.' ], 'audio' => [ 'label' => 'Audio', 'accept' => '.mp3,audio/mpeg', 'allowed_mime' => [ 'audio/mpeg' => 'mp3', 'audio/mp3' => 'mp3', 'audio/x-mpeg' => 'mp3' ], 'max_bytes' => 30 * 1024 * 1024, 'help' => 'Optional. MP3 only. Uploading a new audio file replaces the current audio.' ] ]; } function builderMediaAdminUrl(string $relativePath): string { return '../' . ltrim( $relativePath, '/' ); } function builderMediaUploadErrorMessage( int $error, string $label ): string { if ( $error === UPLOAD_ERR_INI_SIZE || $error === UPLOAD_ERR_FORM_SIZE ) { return $label . ' is larger than the server upload limit.'; } if ($error === UPLOAD_ERR_PARTIAL) { return $label . ' was only partially uploaded. Please try again.'; } if ($error === UPLOAD_ERR_NO_TMP_DIR) { return 'The server has no temporary upload directory.'; } if ($error === UPLOAD_ERR_CANT_WRITE) { return 'The server could not write the uploaded file.'; } if ($error === UPLOAD_ERR_EXTENSION) { return $label . ' was blocked by a server extension.'; } return 'The ' . strtolower($label) . ' upload failed.'; } function prepareBuilderMediaChanges( int $gameId, string $table, array $files, array $post, array $existingMedia ): array { $definition = getBuilderMediaDefinition(); $pending = []; $updatedMedia = is_array($existingMedia) ? $existingMedia : []; $cleanupPaths = []; $newPaths = []; /* * Validate every upload before moving any file. */ foreach ($definition as $kind => $mediaDefinition) { $inputName = 'media_' . $kind; $file = $files[$inputName] ?? null; $removeRequested = !empty( $post['remove_media'][$kind] ); $hasUpload = is_array($file) && isset($file['error']) && (int)$file['error'] !== UPLOAD_ERR_NO_FILE; if (!$hasUpload) { if ( $removeRequested && !empty($updatedMedia[$kind]['path']) ) { $cleanupPaths[] = $updatedMedia[$kind]['path']; unset( $updatedMedia[$kind] ); } continue; } $uploadError = (int)$file['error']; if ($uploadError !== UPLOAD_ERR_OK) { return [ 'media' => $existingMedia, 'cleanup_paths' => [], 'new_paths' => [], 'error' => builderMediaUploadErrorMessage( $uploadError, $mediaDefinition['label'] ) ]; } $size = (int)($file['size'] ?? 0); if ( $size <= 0 || $size > (int)$mediaDefinition['max_bytes'] ) { $maxMb = round( $mediaDefinition['max_bytes'] / 1024 / 1024 ); return [ 'media' => $existingMedia, 'cleanup_paths' => [], 'new_paths' => [], 'error' => $mediaDefinition['label'] . ' must be no larger than ' . $maxMb . ' MB.' ]; } $tmpName = (string)($file['tmp_name'] ?? ''); if ( $tmpName === '' || !is_uploaded_file($tmpName) ) { return [ 'media' => $existingMedia, 'cleanup_paths' => [], 'new_paths' => [], 'error' => 'The uploaded ' . strtolower($mediaDefinition['label']) . ' could not be verified.' ]; } $finfo = finfo_open( FILEINFO_MIME_TYPE ); $mime = $finfo ? finfo_file( $finfo, $tmpName ) : false; if ($finfo) { finfo_close($finfo); } $allowedMime = $mediaDefinition['allowed_mime']; if ( !$mime || !isset($allowedMime[$mime]) ) { return [ 'media' => $existingMedia, 'cleanup_paths' => [], 'new_paths' => [], 'error' => 'Unsupported ' . strtolower($mediaDefinition['label']) . ' file type.' ]; } $pending[$kind] = [ 'tmp_name' => $tmpName, 'extension' => $allowedMime[$mime], 'mime' => $mime, 'size' => $size, 'original_name' => basename( (string)($file['name'] ?? '') ) ]; } if (!$pending) { return [ 'media' => $updatedMedia, 'cleanup_paths' => $cleanupPaths, 'new_paths' => [], 'error' => '' ]; } $safeTable = preg_replace( '/[^A-Za-z0-9_-]/', '_', $table ); $relativeDirectory = 'uploads/media/game_' . $gameId . '/' . $safeTable; $absoluteDirectory = dirname(__DIR__, 2) . '/' . $relativeDirectory; if ( !is_dir($absoluteDirectory) && !mkdir( $absoluteDirectory, 0755, true ) ) { return [ 'media' => $existingMedia, 'cleanup_paths' => [], 'new_paths' => [], 'error' => 'Wayfarer could not create the media upload directory.' ]; } /* * Move validated uploads. If one move fails, remove any new files * already moved during this request. */ foreach ($pending as $kind => $upload) { try { $randomName = bin2hex( random_bytes(16) ); } catch (Throwable $e) { foreach ($newPaths as $newPath) { deleteBuilderMediaFile( $newPath ); } return [ 'media' => $existingMedia, 'cleanup_paths' => [], 'new_paths' => [], 'error' => 'Wayfarer could not generate a secure media filename.' ]; } $fileName = $randomName . '.' . $upload['extension']; $relativePath = $relativeDirectory . '/' . $fileName; $absolutePath = dirname(__DIR__, 2) . '/' . $relativePath; if ( !move_uploaded_file( $upload['tmp_name'], $absolutePath ) ) { foreach ($newPaths as $newPath) { deleteBuilderMediaFile( $newPath ); } return [ 'media' => $existingMedia, 'cleanup_paths' => [], 'new_paths' => [], 'error' => 'Wayfarer could not save the uploaded ' . $kind . '.' ]; } $newPaths[] = $relativePath; if ( !empty($updatedMedia[$kind]['path']) ) { $cleanupPaths[] = $updatedMedia[$kind]['path']; } $updatedMedia[$kind] = [ 'path' => $relativePath, 'mime' => $upload['mime'], 'original_name' => $upload['original_name'], 'size' => $upload['size'] ]; } return [ 'media' => $updatedMedia, 'cleanup_paths' => array_values( array_unique( $cleanupPaths ) ), 'new_paths' => $newPaths, 'error' => '' ]; } function deleteBuilderMediaFile(string $relativePath): void { $relativePath = ltrim( $relativePath, '/' ); /* * Never allow this helper to unlink anything outside Wayfarer's * own media directory. */ if ( strpos( $relativePath, 'uploads/media/' ) !== 0 || strpos( $relativePath, '..' ) !== false ) { return; } $absolutePath = dirname(__DIR__, 2) . '/' . $relativePath; if (is_file($absolutePath)) { @unlink($absolutePath); } } function deleteBuilderMediaFiles(array $paths): void { foreach ( array_unique($paths) as $path ) { if (is_string($path)) { deleteBuilderMediaFile( $path ); } } } ================================================================================ FILE: _v9_backups/20260922-154240/includes/builder/save-record.php ================================================================================ 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $files, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $mediaDefinition = getBuilderMediaDefinition(); $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } /* * WAYFARER MEDIA PREPARE * * All ordinary validation has passed. Validate and move any newly * uploaded files, while preserving existing media when no replacement * was submitted. */ $existingMediaConfig = []; if (!empty($record['config'])) { $decodedMediaConfig = json_decode( $record['config'], true ); if ( is_array($decodedMediaConfig) && !empty($decodedMediaConfig['media']) && is_array($decodedMediaConfig['media']) ) { $existingMediaConfig = $decodedMediaConfig['media']; } } $mediaResult = prepareBuilderMediaChanges( $gameId, $table, $files, $post, $existingMediaConfig ); if ($mediaResult['error'] !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $mediaResult['error'], ]; } $mediaToSave = $mediaResult['media']; $mediaCleanupPaths = $mediaResult['cleanup_paths']; $newMediaPaths = $mediaResult['new_paths']; $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if (!empty($mediaToSave)) { $configToSave['media'] = $mediaToSave; } else { unset( $configToSave['media'] ); } if ( !empty($definition['traits']) || !empty($definition['system_traits']) || !empty($mediaDefinition) ) { $configToSave['traits'] = $traits; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) || !empty($mediaDefinition) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $values[] = $record[$fieldName]; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } /* * WAYFARER MEDIA CLEANUP AFTER SAVE * * Only remove replaced/deleted old media after the database write * succeeded. */ deleteBuilderMediaFiles( $mediaCleanupPaths ); return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: _wayfarer_backups/v83-20260929-163743/admin/builder.php ================================================================================ prepare(" SELECT id, name, config FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; /* WAYFARER_STORYLET_BUILDER_V56 * A + New Branch Storylet link can preselect its parent and choose a sensible * sibling order without making parent_storylet_id part of the generic request. */ if ( $type === 'storylets' && !$id && !empty($_GET['parent_storylet_id']) ) { $requestedParentId = (int)$_GET['parent_storylet_id']; $parentStmt = $pdo->prepare( " SELECT id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $parentStmt->execute([ $requestedParentId, $gameId, ]); if ($parentStmt->fetchColumn()) { $record['parent_storylet_id'] = $requestedParentId; $orderStmt = $pdo->prepare( " SELECT COALESCE(MAX(branch_sort_order), 0) FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? " ); $orderStmt->execute([ $gameId, $requestedParentId, ]); $record['branch_sort_order'] = (int)$orderStmt->fetchColumn() + 10; } } // ================================================================ // SAVE / DELETE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { /* * Media Library associations are applied only after the authored * object has saved successfully and therefore has a stable ID. */ $savedRecordId = (int)$saveResult['id']; $mediaResult = applyBuilderMediaSelections( $pdo, $gameId, $definition['table'], $savedRecordId, $_POST, $_FILES ); if (!$mediaResult['saved']) { /* * The authored object itself has already saved. Keep the * editor on that record and report the media failure * accurately rather than pretending the object save failed. */ $id = $savedRecordId; $record['id'] = $savedRecordId; $error = 'Record saved, but media was not changed: ' . $mediaResult['error']; } else { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) . '&id=' . (int)$saveResult['id'] . '&tab=' . rawurlencode( (string)($_POST['active_tab'] ?? 'basics') ) ); exit; } } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder

================================================================================ FILE: _wayfarer_backups/v83-20260929-163743/includes/builder/editor.php ================================================================================ $groupValues) { if (!is_array($groupValues)) { continue; } foreach ($groupValues as $traitName => $traitValue) { if (!isset($storedSystemTraitValues[$groupName][$traitName])) { $storedSystemTraitValues[$groupName][$traitName] = $traitValue; } } } } } $renderSystemTraits = function () use ( $record, $fields, $systemTraitsSelector, $systemTraitGroups, $storedSystemTraitValues, $storedSystemTraitMaxValues ) { if (!$systemTraitsSelector || empty($systemTraitGroups)) { return; } $selectedGroup = $record[$systemTraitsSelector] ?? ($fields[$systemTraitsSelector]['default'] ?? ''); ?>
$groupDefinition): ?>
>

$traitDefinition): ?>

>

$relationship): ?> $field): ?>

Media

Optional. Reuse an existing asset from this game's Media Library, upload a new asset, or leave the slot empty.

$mediaDefinition): ?>

Maximum upload: .

Current: ()



No existing assets have been uploaded to this game yet.

$field): ?>





Traits

Traits are additional values associated with this that can change during play.


Danger Zone

Permanently delete this . This cannot be undone.

================================================================================ FILE: _wayfarer_backups/v83-20260929-163743/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects, array $storyletBindings = [], int $currentStoryletId = 0 ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $allowedTargets = array_replace( gameplayRuntimeStateTargets( $pdo, $gameId ), gameplayStoryletBindingEffectTargets( $pdo, $gameId, $storyletBindings ), gameplayStoryletNavigationEffectTargets( $pdo, $gameId, $currentStoryletId ) ); $result = gameplayBuildEffectsFromPost( $allowedTargets, $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); if (($result['error'] ?? '') !== '') { return $result; } $transitionCount = 0; foreach ( $result['effects'] ?? [] as $effect ) { if ( is_array($effect) && (string)( $effect['target_scope'] ?? '' ) === 'storylet' && (string)( $effect['target_type'] ?? '' ) === 'transition' ) { $transitionCount++; } } if ($transitionCount > 1) { return [ 'effects' => [], 'error' => 'A Storylet may have only one Storylet → Continue To Effect.', ]; } return $result; } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /* * WAYFARER_LINEAR_TRAVEL_SAVE_V72 * * Dedicated Linear Travel is deliberately opt-in. A null travel result means * the Region should continue using the legacy Position Tick Effect path. */ function buildRegionTravelFromPost( array $record, array $postedTravel ): array { $navigationType = strtolower( trim( (string)( $record['navigation_type'] ?? '' ) ) ); if ($navigationType !== 'linear') { return [ 'travel' => null, 'error' => '', ]; } if (empty($postedTravel['enabled'])) { return [ 'travel' => null, 'error' => '', ]; } $baseDistance = $postedTravel['base_distance'] ?? ''; if ( !is_numeric($baseDistance) || (float)$baseDistance <= 0 ) { return [ 'travel' => null, 'error' => 'Base Distance Per Tick must be greater than zero.', ]; } $baseDistance = (float)$baseDistance; if (floor($baseDistance) === $baseDistance) { $baseDistance = (int)$baseDistance; } return [ 'travel' => [ 'enabled' => true, 'base_distance' => $baseDistance, 'allow_turnaround' => !empty( $postedTravel[ 'allow_turnaround' ] ), ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_LINEAR_TRAVEL_SAVE_V72 */ $regionTravel = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionTravelResult = buildRegionTravelFromPost( $record, isset($post['travel']) && is_array($post['travel']) ? $post['travel'] : [] ); $regionTravel = $regionTravelResult['travel'] ?? null; if ( ($regionTravelResult['error'] ?? '') !== '' ) { $error = (string)$regionTravelResult['error']; } } /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ /* WAYFARER_STORYLET_BINDINGS_V79 */ $storyletBindings = []; $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $bindingResult = gameplayBuildStoryletBindingsFromPost( isset($post['bindings']) && is_array($post['bindings']) ? array_values($post['bindings']) : [] ); $storyletBindings = $bindingResult['bindings']; if ($bindingResult['error'] !== '') { $error = $bindingResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [], $storyletBindings, $postedId ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_LINEAR_TRAVEL_SAVE_V72 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTravel !== null) { $configToSave['travel'] = $regionTravel; } else { unset( $configToSave['travel'] ); } } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { /* WAYFARER_STORYLET_BINDINGS_V79 */ if ($storyletBindings) { $configToSave['bindings'] = array_values($storyletBindings); } else { unset($configToSave['bindings']); } if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: _wayfarer_backups/v83-20260929-163743/includes/builder/storylet-gameplay.php ================================================================================

Bindings

Bindings select one runtime Entity when this Storylet fires. The selected Entity is reused by every Binding Effect and by narrative placeholders for this Storylet occurrence.

Use a stable lowercase Key in narrative text, for example {{victim.name}}. Random Party Character chooses from Characters currently in the party. Random Possessed Item chooses from Items whose current Quantity is greater than 0.

$bindingRow ): ?>
Narrative:

Conditions

Conditions determine whether this Storylet is eligible. A root Manual Storylet can be offered only while these are true. Automatic and Chance Storylets can fire only while eligible. For a child Storylet, these same Conditions determine whether its Branch is available to the player.

Effects

These Effects run once when the player enters this Storylet. Each Effect may use ordered conditional results and an optional Otherwise result. Targets come from Wayfarer's universal runtime-state catalog, so the Storylet may inspect or change Game, Player, Region, Location, Item, and Character Trait state. Definition metadata such as min/max is not a target.

Branches

A Branch is another Storylet whose Parent Storylet is this Storylet. The child Storylet's Conditions decide whether the Branch is available; entering that child runs its Effects. Children may have their own Branches, so this structure can nest to any practical depth without a separate branch-object schema.

Save this Storylet first. After it has an ID, you can add child Branch Storylets here.

prepare( " SELECT id, name, branch_label, branch_sort_order, trigger_type, status FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? ORDER BY branch_sort_order, name, id " ); $branchStmt->execute([ $gameId, (int)$id, ]); $storyletBranches = $branchStmt->fetchAll( PDO::FETCH_ASSOC ); ?>

+ New Branch Storylet

No child Branches have been created yet.

Order Branch Storylet Trigger Status
================================================================================ FILE: _wayfarer_backups/v83-20260929-163743/includes/gameplay/storylet-bindings.php ================================================================================ 'Random Party Character', 'random_possessed_item' => 'Random Possessed Item', ]; } function gameplayStoryletBindingNormalizeKey( string $value ): string { return strtolower( trim($value) ); } function gameplayStoryletBindingNormalizeTraitName( string $value ): string { $value = trim( preg_replace( '/\s+/u', ' ', $value ) ?? $value ); if (function_exists('mb_strtolower')) { return mb_strtolower( $value, 'UTF-8' ); } return strtolower($value); } function gameplayStoryletBindingNamedTraitId( string $traitName ): string { return 'binding_name_' . bin2hex( gameplayStoryletBindingNormalizeTraitName( $traitName ) ); } function gameplayBuildStoryletBindingsFromPost( $postedBindings ): array { $bindings = []; $error = ''; if ($postedBindings === null || $postedBindings === '') { return [ 'bindings' => [], 'error' => '', ]; } if (!is_array($postedBindings)) { return [ 'bindings' => [], 'error' => 'Storylet Bindings must be a list.', ]; } $selectors = gameplayStoryletBindingSelectorOptions(); $seen = []; foreach ( array_values($postedBindings) as $index => $row ) { if (!is_array($row)) { continue; } $key = gameplayStoryletBindingNormalizeKey( (string)( $row['key'] ?? '' ) ); $label = trim( (string)( $row['label'] ?? '' ) ); $selector = strtolower( trim( (string)( $row['selector'] ?? '' ) ) ); /* * Ignore a completely blank newly-added row. */ if ( $key === '' && $label === '' && $selector === '' ) { continue; } $rowNumber = $index + 1; if ( $key === '' || preg_match( '/^[a-z][a-z0-9_]{0,31}$/', $key ) !== 1 ) { $error = 'Binding ' . $rowNumber . ' needs a Key beginning with a letter and containing only lowercase letters, numbers, and underscores (maximum 32 characters).'; break; } if (isset($seen[$key])) { $error = 'Binding Keys must be unique. "' . $key . '" is used more than once.'; break; } if ($label === '') { $error = 'Binding ' . $rowNumber . ' needs a Label.'; break; } $labelLength = function_exists('mb_strlen') ? mb_strlen( $label, 'UTF-8' ) : strlen($label); if ($labelLength > 80) { $error = 'Binding ' . $rowNumber . ' Label must be 80 characters or fewer.'; break; } if (!isset($selectors[$selector])) { $error = 'Binding ' . $rowNumber . ' must use a valid selector.'; break; } $seen[$key] = true; $bindings[] = [ 'key' => $key, 'label' => $label, 'selector' => $selector, ]; } return [ 'bindings' => $bindings, 'error' => $error, ]; } function gameplayStoryletBindingTraitCatalog( PDO $pdo, int $gameId, string $entityType ): array { $entityType = strtolower( trim($entityType) ); if ( $gameId <= 0 || !in_array( $entityType, ['character', 'item'], true ) ) { return []; } $stmt = $pdo->prepare( " SELECT id, config FROM wf_entities WHERE game_id = ? AND status = 'active' AND LOWER(entity_type) = ? ORDER BY id " ); $stmt->execute([ $gameId, $entityType, ]); $catalog = []; foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $config = json_decode( (string)( $row['config'] ?? '' ), true ); if (!is_array($config)) { $config = []; } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || ( array_key_exists( 'active', $trait ) && !$trait['active'] ) || !empty($trait['system']) ) { continue; } $name = trim( (string)( $trait['name'] ?? '' ) ); if ($name === '') { continue; } $type = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); if ($type === 'binary') { $type = 'boolean'; } if ( !in_array( $type, ['number', 'boolean'], true ) ) { continue; } $normalizedName = gameplayStoryletBindingNormalizeTraitName( $name ); if ($normalizedName === '') { continue; } if (!isset($catalog[$normalizedName])) { $catalog[$normalizedName] = [ 'name' => $name, 'data_type' => $type, 'conflict' => false, ]; continue; } if ( $catalog[$normalizedName]['data_type'] !== $type ) { /* * A random binding cannot safely target one logical Trait name * when different Entities define it with incompatible types. */ $catalog[$normalizedName]['conflict'] = true; } } } foreach ($catalog as $name => $definition) { if (!empty($definition['conflict'])) { unset($catalog[$name]); } } uasort( $catalog, static function ( array $a, array $b ): int { return strcasecmp( (string)$a['name'], (string)$b['name'] ); } ); return $catalog; } function gameplayStoryletBindingEffectTargets( PDO $pdo, int $gameId, array $bindings ): array { $targets = []; $characterTraits = gameplayStoryletBindingTraitCatalog( $pdo, $gameId, 'character' ); $itemTraits = gameplayStoryletBindingTraitCatalog( $pdo, $gameId, 'item' ); foreach ($bindings as $binding) { if (!is_array($binding)) { continue; } $key = gameplayStoryletBindingNormalizeKey( (string)( $binding['key'] ?? '' ) ); $label = trim( (string)( $binding['label'] ?? $key ) ); $selector = strtolower( trim( (string)( $binding['selector'] ?? '' ) ) ); if ( $key === '' || preg_match( '/^[a-z][a-z0-9_]{0,31}$/', $key ) !== 1 ) { continue; } $scope = 'binding:' . $key; $group = 'Binding — ' . ( $label !== '' ? $label : $key ); if ($selector === 'random_party_character') { $targetId = 'sys_character_party_member'; $targetKey = $scope . ':trait:' . $targetId; $targets[$targetKey] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $targetId, 'target_system_key' => 'party_member', 'trait_type' => 'boolean', 'data_type' => 'boolean', 'semantic_type' => 'storylet_binding_trait', 'condition_operators' => [ 'equals', 'not_equals', ], 'effect_operations' => [ 'set', ], 'supports_random' => false, 'group' => $group, 'label' => 'Party Membership', 'helper' => 'Random Party Character', ]; foreach ( $characterTraits as $trait ) { $namedTargetId = gameplayStoryletBindingNamedTraitId( (string)$trait['name'] ); $targetKey = $scope . ':trait:' . $namedTargetId; $dataType = (string)$trait['data_type']; $targets[$targetKey] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $namedTargetId, 'trait_type' => $dataType, 'data_type' => $dataType, 'semantic_type' => 'storylet_binding_named_trait', 'condition_operators' => $dataType === 'boolean' ? [ 'equals', 'not_equals', ] : wayfarerConditionOperators(), 'effect_operations' => $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ], 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => (string)$trait['name'], 'helper' => 'Random Party Character', ]; } continue; } if ($selector === 'random_possessed_item') { $targetId = 'sys_item_quantity'; $targetKey = $scope . ':trait:' . $targetId; $targets[$targetKey] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $targetId, 'target_system_key' => 'quantity', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'storylet_binding_trait', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => [ 'set', 'add', 'subtract', ], 'supports_random' => true, 'group' => $group, 'label' => 'Quantity', 'helper' => 'Random Possessed Item', ]; foreach ( $itemTraits as $trait ) { $namedTargetId = gameplayStoryletBindingNamedTraitId( (string)$trait['name'] ); $targetKey = $scope . ':trait:' . $namedTargetId; $dataType = (string)$trait['data_type']; $targets[$targetKey] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $namedTargetId, 'trait_type' => $dataType, 'data_type' => $dataType, 'semantic_type' => 'storylet_binding_named_trait', 'condition_operators' => $dataType === 'boolean' ? [ 'equals', 'not_equals', ] : wayfarerConditionOperators(), 'effect_operations' => $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ], 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => (string)$trait['name'], 'helper' => 'Random Possessed Item', ]; } } } return $targets; } ================================================================================ FILE: _wayfarer_backups/v87-20260929-192300/config/builder-types.php ================================================================================ [ 'label' => 'Regions', 'singular' => 'Region', 'table' => 'wf_regions', 'list_field' => 'name', 'traits' => true, /* * Permanent deletion is available from the edit screen. * A Region cannot be deleted while Locations still belong to it. */ 'delete' => [ 'enabled' => true, 'dependencies' => [ [ 'table' => 'wf_locations', 'foreign_key' => 'region_id', 'label' => 'Locations' ] ] ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'navigation_type' => [ 'label' => 'Navigation Type', 'type' => 'select', 'required' => true, 'options' => [ 'linear' => 'Linear', 'grid' => 'Grid', 'freeform' => 'Freeform' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], /* WAYFARER_LINEAR_DISTANCE_LABEL_V61 */ 'config_fields' => [ 'distance_label' => [ 'label' => 'Distance Label', 'type' => 'text', 'default' => 'units', 'help' => 'Player-facing unit used for Linear travel distance, such as miles, km, leagues, or units.' ] ] ], 'locations' => [ 'label' => 'Locations', 'singular' => 'Location', 'table' => 'wf_locations', 'list_field' => 'name', 'traits' => true, 'list_columns' => [ 'name' => [ 'label' => 'Name', 'source' => 'field', 'field' => 'name', 'sortable' => true ], 'region' => [ 'label' => 'Region', 'source' => 'relationship', 'relationship' => 'region_id', 'sortable' => true ], 'position' => [ 'label' => 'Position', 'source' => 'config', 'field' => 'position', 'type' => 'number', 'sortable' => true ] ], /* * Permanent deletion is available from the edit screen. */ 'delete' => [ 'enabled' => true ], 'scope' => [ 'type' => 'region', 'field' => 'region_id', 'scope_table' => 'wf_regions', 'scope_key' => 'id', 'game_field' => 'game_id' ], 'relationships' => [ 'region_id' => [ 'label' => 'Region', 'type' => 'select_record', 'table' => 'wf_regions', 'value_field' => 'id', 'label_field' => 'name', 'game_field' => 'game_id', 'required' => true ] ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], 'config_fields' => [ 'position' => [ 'label' => 'Position', 'type' => 'number', 'min' => 0, 'required' => true, 'condition' => [ 'relationship' => 'region_id', 'source_table' => 'wf_regions', 'source_key' => 'id', 'source_field' => 'navigation_type', 'equals' => 'linear' ] ] ] ], 'entities' => [ 'label' => 'Entities', 'singular' => 'Entity', 'table' => 'wf_entities', 'list_field' => 'name', 'traits' => true, /* * Permanent deletion is available from the edit screen. * Additional dependency rules can be added here later as * Storylets and other authored objects begin referencing Entities. */ 'delete' => [ 'enabled' => true ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'entity_type' => [ 'label' => 'Entity Type', 'type' => 'select', 'required' => true, 'default' => 'item', 'options' => [ 'item' => 'Item', 'character' => 'Character' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], /* * System traits use the same stored config.traits collection as * author-created traits. They are locked in the authoring UI. */ 'system_traits' => [ 'selector_field' => 'entity_type', 'after_field' => 'entity_type', 'groups' => [ 'item' => [ 'label' => 'Item Settings', 'help' => 'These are required Item properties used by Wayfarer. ' . 'They cannot be removed or renamed.', 'traits' => [ 'quantity' => [ /* * WAYFARER_ITEM_QUANTITY_CONTEXT_V44 */ 'label' => 'Starting Quantity', 'type' => 'number', 'default' => 0, 'min' => 0, 'required' => true, /* * The minimum is a Wayfarer system rule, not an * author-editable gameplay setting. */ 'show_system_min' => true, 'min_label' => 'Minimum Quantity', 'min_help' => 'Wayfarer Item quantities cannot fall below 0.', 'allow_author_max' => true, 'max_label' => 'Maximum Quantity', 'max_help' => 'The maximum quantity the player can hold during gameplay. ' . 'Leave blank for no maximum.', 'help' => 'Sets the quantity of this Item when gameplay is initialized. ' . 'Effects may increase or decrease the quantity during play. ' . 'A Starting Quantity of 0 means the player begins without this Item.' ], 'inventory_visibility' => [ 'label' => 'Inventory Visibility', 'type' => 'select', 'default' => 'while_possessed', 'required' => true, 'options' => [ 'while_possessed' => 'Only while possessed', 'once_discovered' => 'Once discovered', 'always' => 'Always', 'never' => 'Never' ], 'help' => 'Controls when this item appears in the player inventory. ' . '"Only while possessed" hides the item whenever its quantity ' . 'is 0. "Once discovered" hides it until the player first ' . 'obtains or discovers it, then keeps it visible even if its ' . 'quantity later returns to 0. "Always" shows the item from ' . 'the beginning of the game. "Never" keeps the item hidden ' . 'from the player inventory while still allowing gameplay ' . 'conditions and effects to use it.' ] ] ], 'character' => [ 'label' => 'Character Settings', 'help' => 'These are required Character properties used by Wayfarer. ' . 'They cannot be removed or renamed.', 'traits' => [ 'party_member' => [ 'label' => 'Starts in Party', 'type' => 'binary', 'default' => 0, 'required' => true, 'help' => 'Determines whether this character begins the game ' . 'as a member of the player party. No stores 0; ' . 'Yes stores 1. Storylets can later change this ' . 'state to add or remove the character from the party.' ] ] ] ] ] ], /* WAYFARER_STORYLET_BUILDER_V56 */ 'storylets' => [ 'label' => 'Storylets', 'singular' => 'Storylet', 'table' => 'wf_storylets', 'list_field' => 'name', 'list_columns' => [ 'name' => [ 'label' => 'Name', 'source' => 'field', 'field' => 'name', 'sortable' => true ], 'parent' => [ 'label' => 'Parent', 'source' => 'relationship', 'relationship' => 'parent_storylet_id', 'sortable' => true ], 'trigger' => [ 'label' => 'Trigger', 'source' => 'field', 'field' => 'trigger_type', 'sortable' => true ], 'status' => [ 'label' => 'Status', 'source' => 'field', 'field' => 'status', 'sortable' => true ] ], /* * A Storylet with children cannot be permanently removed until its * Branch Storylets have been detached or deleted. */ 'delete' => [ 'enabled' => true, 'dependencies' => [ [ 'table' => 'wf_storylets', 'foreign_key' => 'parent_storylet_id', 'label' => 'Branch Storylets' ] ] ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'relationships' => [ 'parent_storylet_id' => [ 'label' => 'Parent Storylet', 'type' => 'select_record', 'table' => 'wf_storylets', 'value_field' => 'id', 'label_field' => 'name', 'game_field' => 'game_id', 'required' => false, 'exclude_current' => true, 'help' => 'Leave blank for a root Storylet. Select a parent to make ' . 'this Storylet one of that Storylet\'s player Branches.' ] ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true, 'help' => 'Author-facing Storylet name and default player-facing title.' ], 'description' => [ 'label' => 'Narrative / Description', 'type' => 'textarea', 'help' => 'Narrative shown when this Storylet opens. It may also be ' . 'used as preview text when the Storylet is offered.' ], 'branch_label' => [ 'label' => 'Branch Label', 'type' => 'text', 'help' => 'Used when this Storylet appears as a child Branch. Leave ' . 'blank to use the Storylet Name.' ], 'branch_sort_order' => [ 'label' => 'Branch Order', 'type' => 'number', 'min' => 0, 'default' => 0, 'help' => 'Lower numbers appear first among sibling Branches. Ignored ' . 'for root Storylets.' ], 'trigger_type' => [ 'label' => 'Trigger Type', 'type' => 'select', 'required' => true, 'default' => 'manual', 'options' => [ 'manual' => 'Manual', 'automatic' => 'Automatic', 'chance' => 'Chance' ], 'help' => 'Manual: offer while eligible. Automatic: fire when it becomes ' . 'eligible. Chance: while eligible, roll the base chance each tick.' ], 'max_occurrences' => [ 'label' => 'Maximum Occurrences', 'type' => 'number', 'min' => 1, 'help' => 'Leave blank for unlimited occurrences.' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], 'config_fields' => [ 'priority' => [ 'label' => 'Priority', 'type' => 'number', 'default' => 100, 'help' => 'Author-controlled gameplay order for competing Storylets. ' . 'Lower numbers are evaluated first.' ], 'tick_cost' => [ 'label' => 'Tick Cost', 'type' => 'number', 'min' => 0, 'default' => 0, 'help' => 'How many normal gameplay ticks resolving this Storylet consumes. ' . '0 consumes no time; 1 or more runs the normal tick pipeline.' ], 'chance_percent' => [ 'label' => 'Base Chance %', 'type' => 'number', 'min' => 0, 'max' => 100, 'step' => 0.01, 'help' => 'Used only by Chance Storylets. Eligibility is checked first; ' . 'then this percentage is rolled on each gameplay tick.' ], 'interrupts_travel' => [ 'label' => 'Interrupts Travel', 'type' => 'select', 'default' => '0', 'options' => [ '0' => 'No', '1' => 'Yes' ], 'help' => 'Used by Automatic and Chance Storylets. If Yes, firing the ' . 'Storylet stops the current travel activity. Manual Storylets ' . 'are selected by the player and do not use this setting.' ] ] ] ]; ================================================================================ FILE: _wayfarer_backups/v87-20260929-192300/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* |-------------------------------------------------------------------------- | Party-member Trait catalog | WAYFARER_PARTY_TARGETS_V85 |-------------------------------------------------------------------------- | | Player Traits and authored Character Entity Traits may represent the same | logical per-person property (for example, Health). Party-level authoring | addresses that property by normalized name so one Effect can operate across | the current party without exposing runtime player-slot numbers. | */ function gameplayPartyTraitNormalizeName( string $value ): string { $value = trim( preg_replace( '/\\s+/u', ' ', $value ) ?? $value ); if (function_exists('mb_strtolower')) { return mb_strtolower( $value, 'UTF-8' ); } return strtolower($value); } function gameplayPartyTraitTargetId( string $traitName ): string { return 'party_name_' . bin2hex( gameplayPartyTraitNormalizeName( $traitName ) ); } function gameplayPartyTraitCatalog( PDO $pdo, int $gameId, ?array $playerTraitsOverride = null ): array { if ($gameId <= 0) { return []; } $catalog = []; $append = static function ( array &$catalog, array $trait ): void { if ( !is_array($trait) || !empty($trait['system']) || ( array_key_exists('active', $trait) && !$trait['active'] ) ) { return; } $name = trim( (string)( $trait['name'] ?? '' ) ); if ($name === '') { return; } $type = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); if ($type === 'binary') { $type = 'boolean'; } if ( !in_array( $type, ['number', 'boolean'], true ) ) { return; } $normalized = gameplayPartyTraitNormalizeName( $name ); if ($normalized === '') { return; } if (!isset($catalog[$normalized])) { $catalog[$normalized] = [ 'name' => $name, 'data_type' => $type, 'conflict' => false, ]; return; } if ( (string)$catalog[$normalized]['data_type'] !== $type ) { $catalog[$normalized]['conflict'] = true; } }; if ($playerTraitsOverride === null) { $stmt = $pdo->prepare( 'SELECT config FROM wf_games WHERE id = ? LIMIT 1' ); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); $config = $raw === false ? [] : gameplayRuntimeStateDecodeConfig( $raw ); $playerTraits = isset($config['player_traits']) && is_array($config['player_traits']) ? $config['player_traits'] : []; } else { $playerTraits = $playerTraitsOverride; } foreach ($playerTraits as $trait) { if (is_array($trait)) { $append( $catalog, $trait ); } } $stmt = $pdo->prepare( " SELECT config FROM wf_entities WHERE game_id = ? AND status = 'active' AND LOWER(entity_type) = 'character' ORDER BY id " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (is_array($trait)) { $append( $catalog, $trait ); } } } foreach ($catalog as $normalized => $definition) { if (!empty($definition['conflict'])) { unset($catalog[$normalized]); } } uasort( $catalog, static function ( array $a, array $b ): int { return strcasecmp( (string)$a['name'], (string)$b['name'] ); } ); return $catalog; } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } /* WAYFARER_PARTY_TARGETS_V85 */ foreach ( gameplayPartyTraitCatalog( $pdo, $gameId, $playerTraits ) as $partyTrait ) { $partyName = (string)( $partyTrait['name'] ?? '' ); $partyDataType = (string)( $partyTrait['data_type'] ?? 'text' ); if ( $partyName === '' || !in_array( $partyDataType, ['number', 'boolean'], true ) ) { continue; } $partyTargetId = gameplayPartyTraitTargetId( $partyName ); $partyKey = 'party:trait:' . $partyTargetId; $targets[$partyKey] = [ 'scope' => 'party', 'target_scope' => 'party', 'target_type' => 'trait', 'target_id' => $partyTargetId, 'trait_type' => $partyDataType, 'data_type' => $partyDataType, 'semantic_type' => 'party_all_trait', 'helper' => 'Every current party member that exposes this Trait', 'condition_operators' => $partyDataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ], 'effect_operations' => $partyDataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ], 'supports_random' => $partyDataType === 'number', /* * A Party Condition needs an explicit Any/All quantifier model. * Until that exists, this aggregate target is Effect-only. */ 'condition_hidden' => true, 'group' => 'Party', 'label' => 'All Party Members → ' . $partyName, 'hierarchy' => [ [ 'key' => 'party', 'label' => 'Party', ], [ 'key' => 'all_party_members', 'label' => 'All Party Members', ], [ 'key' => $partyTargetId, 'label' => $partyName, ], ], ]; if ($partyDataType === 'boolean') { $targets[$partyKey]['value_options'] = [ '0' => 'False', '1' => 'True', ]; } } /* * Numbered Player slots remain runtime-compatible for existing authored * data, but they are implementation slots rather than useful new targets. * New authoring uses Storylet Binding -> Random Party Member or * Party -> All Party Members instead. */ for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); $playerKey = 'player:' . $slot . ':trait:' . $traitId; if ( $traitId !== '' && isset($targets[$playerKey]) ) { $targets[$playerKey]['condition_hidden'] = true; $targets[$playerKey]['effect_hidden'] = true; } } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ /* |-------------------------------------------------------------------------- | Navigation Condition Targets |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITION_TARGETS_V75 | WAYFARER_LIVE_LOCATION_ANCHORS_V76 | | Numeric Position Conditions are Region-qualified. | | New Location Conditions reference wf_locations.id as target_id. The | comparison itself is against zero because runtime resolves the target to: | | current position - current anchor position | | That makes a Location a live spatial anchor: | | = 0 => At | != 0 => Not at | < 0 => Before | <= 0 => At or before | > 0 => After | >= 0 => At or after | | The V75 snapshot target navigation:location:location remains hidden for | backward compatibility only. | */ function gameplayConditionNavigationPositionToken( $value ): string { if (!is_numeric($value)) { return ''; } $number = (float)$value; if (is_finite($number) && floor($number) == $number) { return (string)((int)$number); } $token = rtrim( rtrim( sprintf('%.12F', $number), '0' ), '.' ); return $token === '-0' ? '0' : $token; } function gameplayNavigationConditionTargets( PDO $pdo, int $gameId ): array { $targets = []; /* * Compatibility only. Existing saved unqualified Position Conditions * still render and save, but cannot be newly authored. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position_legacy', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'group' => 'Navigation', 'label' => 'Position (legacy unqualified)', 'condition_hidden' => true, ]; /* * Compatibility only. V75 stored Region + Position in the value itself. * New V76 Conditions never use this target. */ $targets['navigation:location:location'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => 'location', 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'navigation_location_v75_snapshot', 'helper' => 'V75 snapshot', 'condition_operators' => [ 'equals', 'not_equals', ], 'group' => 'Navigation', 'label' => 'Location (legacy V75 snapshot)', 'condition_hidden' => true, ]; if ($gameId <= 0) { return $targets; } /* * Fixed numeric Position, explicitly qualified to one linear Region. */ $regionStmt = $pdo->prepare( " SELECT id, name, navigation_type FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $regionStmt->execute([ $gameId ]); foreach ( $regionStmt->fetchAll(PDO::FETCH_ASSOC) as $region ) { $regionId = (int)($region['id'] ?? 0); if ($regionId <= 0) { continue; } if ( strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) !== 'linear' ) { continue; } $regionName = trim( (string)( $region['name'] ?? ('Region ' . $regionId) ) ); $scope = 'navigation:' . $regionId; $key = $scope . ':position:position'; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_region_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'group' => 'Navigation — Positions', 'label' => $regionName, ]; } /* * Named Locations are LIVE references. Each Location is its own target, * identified by wf_locations.id. Position is shown only as authoring help; * it is resolved again from current Location config at runtime. */ $locationStmt = $pdo->prepare( " SELECT l.id, l.name, l.config, l.region_id, r.name AS region_name, r.navigation_type FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $locationStmt->execute([ $gameId ]); foreach ( $locationStmt->fetchAll(PDO::FETCH_ASSOC) as $location ) { if ( strtolower( trim( (string)( $location['navigation_type'] ?? '' ) ) ) !== 'linear' ) { continue; } $locationId = (int)( $location['id'] ?? 0 ); if ($locationId <= 0) { continue; } $config = gameplayRuntimeStateDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $position = gameplayConditionNavigationPositionToken( $config['position'] ); if ($position === '') { continue; } $locationName = trim( (string)( $location['name'] ?? ('Location ' . $locationId) ) ); $regionName = trim( (string)( $location['region_name'] ?? 'Region' ) ); $key = 'navigation:location:' . $locationId; $targets[$key] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => (string)$locationId, 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_location_anchor', 'helper' => $regionName . ' · Position ' . $position, 'condition_operators' => [ 'equals', 'not_equals', 'less_than', 'less_than_or_equal', 'greater_than', 'greater_than_or_equal', ], 'condition_operator_labels' => [ 'equals' => 'At', 'not_equals' => 'Not at', 'less_than' => 'Before', 'less_than_or_equal' => 'At or before', 'greater_than' => 'After', 'greater_than_or_equal' => 'At or after', ], /* * The runtime value is a signed delta from this anchor, so every * comparison is against zero. Keep that implementation detail out * of the authoring UI while still posting a normal Condition value. */ 'value_options' => [ '0' => 'Anchor', ], 'condition_hide_value' => true, 'group' => 'Navigation — Locations', 'label' => $locationName, ]; } return $targets; } /* * Allows Condition POST validation to recognize V76 navigation-only target * shapes even when a caller supplied the older universal Effect catalog. */ /* |-------------------------------------------------------------------------- | Storylet Navigation Effect targets | WAYFARER_STORYLET_ROOT_CONNECTIONS_V82 |-------------------------------------------------------------------------- | | Explicit Storylet navigation is an Effect-only target. A Storylet may | continue directly to another root Storylet after its other Effects resolve. | Child Storylets remain structural Branches and are deliberately not exposed | as destinations here. */ function gameplayStoryletNavigationEffectTargets( PDO $pdo, int $gameId, int $currentStoryletId = 0 ): array { $targets = []; if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, status FROM wf_storylets WHERE game_id = ? AND ( parent_storylet_id IS NULL OR parent_storylet_id = 0 ) ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $storylet ) { $storyletId = (int)( $storylet['id'] ?? 0 ); if ( $storyletId <= 0 || ( $currentStoryletId > 0 && $storyletId === $currentStoryletId ) ) { continue; } $name = trim( (string)( $storylet['name'] ?? '' ) ); if ($name === '') { $name = 'Storylet ' . $storyletId; } $status = strtolower( trim( (string)( $storylet['status'] ?? '' ) ) ); $helper = $status !== '' ? ucfirst($status) . ' root Storylet' : 'Root Storylet'; $key = 'storylet:transition:' . $storyletId; $targets[$key] = [ 'scope' => 'storylet', 'target_scope' => 'storylet', 'target_type' => 'transition', 'target_id' => (string)$storyletId, 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'storylet_transition', 'helper' => $helper, 'effect_operations' => [ 'continue', ], 'supports_random' => false, 'group' => 'Storylet Navigation', 'label' => $name . ' → Continue To', 'hierarchy' => [ [ 'key' => 'storylet', 'label' => 'Storylet', ], [ 'key' => 'storylet:' . $storyletId, 'label' => $name, ], [ 'key' => 'continue_to', 'label' => 'Continue To', ], ], ]; } return $targets; } /* |-------------------------------------------------------------------------- | Hierarchical target-ready Time Conditions | WAYFARER_HIERARCHICAL_TARGETS_V80 |-------------------------------------------------------------------------- | | The hierarchy UI consumes a generic target catalog. New systems can opt in | simply by supplying a three-level hierarchy array: | | Type -> Specific Target -> Property | | Current Game Time is Condition-only. Effects do not modify the clock here. | Calendar / Seasons can later register additional Time properties without | changing the picker. */ function gameplayTimeConditionTargets( PDO $pdo, int $gameId ): array { $targets = []; if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ($raw === false) { return $targets; } $config = gameplayRuntimeStateDecodeConfig( $raw ); $time = isset($config['time']) && is_array($config['time']) ? $config['time'] : []; $mode = strtolower( trim( (string)( $time['mode'] ?? 'none' ) ) ); if ( $mode !== 'days' && $mode !== 'hours' ) { return $targets; } $base = [ 'scope' => 'time', 'target_scope' => 'time', 'target_type' => 'clock', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'game_time', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'group' => 'Time', ]; $targets['time:clock:day'] = array_merge( $base, [ 'target_id' => 'day', 'label' => 'Game Clock → Day', 'hierarchy' => [ [ 'key' => 'time', 'label' => 'Time', ], [ 'key' => 'game_clock', 'label' => 'Game Clock', ], [ 'key' => 'day', 'label' => 'Day', ], ], ] ); if ($mode === 'hours') { $targets['time:clock:hour'] = array_merge( $base, [ 'target_id' => 'hour', 'label' => 'Game Clock → Hour', 'hierarchy' => [ [ 'key' => 'time', 'label' => 'Time', ], [ 'key' => 'game_clock', 'label' => 'Game Clock', ], [ 'key' => 'hour', 'label' => 'Hour', ], ], ] ); } return $targets; } function gameplaySyntheticTimeConditionTarget( string $target ): ?array { if ($target === 'time:clock:day') { return [ 'scope' => 'time', 'target_scope' => 'time', 'target_type' => 'clock', 'target_id' => 'day', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'game_time', 'condition_operators' => wayfarerConditionOperators(), ]; } if ($target === 'time:clock:hour') { return [ 'scope' => 'time', 'target_scope' => 'time', 'target_type' => 'clock', 'target_id' => 'hour', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'game_time', 'condition_operators' => wayfarerConditionOperators(), ]; } return null; } function gameplaySyntheticNavigationConditionTarget( string $target ): ?array { if ( preg_match( '/^navigation:([1-9][0-9]*):position:position$/', $target, $match ) === 1 ) { $scope = 'navigation:' . (int)$match[1]; return [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_region_position', 'condition_operators' => wayfarerConditionOperators(), ]; } if ( preg_match( '/^navigation:location:([1-9][0-9]*)$/', $target, $match ) === 1 ) { return [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => (string)((int)$match[1]), 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_location_anchor', 'condition_operators' => [ 'equals', 'not_equals', 'less_than', 'less_than_or_equal', 'greater_than', 'greater_than_or_equal', ], ]; } /* V75 snapshot compatibility. */ if ($target === 'navigation:location:location') { return [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => 'location', 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'navigation_location_v75_snapshot', 'condition_operators' => [ 'equals', 'not_equals', ], ]; } return null; } function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return array_replace( gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ), gameplayNavigationConditionTargets( $pdo, $gameId ), gameplayTimeConditionTargets( $pdo, $gameId ) ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; /* * WAYFARER_LOCATION_CONDITION_TARGETS_V75 * * Older callers may still pass the universal Effect target catalog. * Region-qualified Position and Location targets are Condition-only, * so synthesize their definitions here when needed. */ if ( $target !== '' && !isset($allowedTargets[$target]) ) { $syntheticTarget = gameplaySyntheticNavigationConditionTarget( $target ); if ( $syntheticTarget === null && function_exists( 'gameplaySyntheticTimeConditionTarget' ) ) { $syntheticTarget = gameplaySyntheticTimeConditionTarget( $target ); } if ($syntheticTarget !== null) { $allowedTargets[$target] = $syntheticTarget; } } if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: _wayfarer_backups/v87-20260929-192300/includes/gameplay/storylet-bindings.php ================================================================================ 'Random Party Member', 'random_possessed_item' => 'Random Possessed Item', ]; } function gameplayStoryletBindingNormalizeKey( string $value ): string { return strtolower( trim($value) ); } function gameplayStoryletBindingNormalizeTraitName( string $value ): string { $value = trim( preg_replace( '/\s+/u', ' ', $value ) ?? $value ); if (function_exists('mb_strtolower')) { return mb_strtolower( $value, 'UTF-8' ); } return strtolower($value); } function gameplayStoryletBindingNamedTraitId( string $traitName ): string { return 'binding_name_' . bin2hex( gameplayStoryletBindingNormalizeTraitName( $traitName ) ); } function gameplayBuildStoryletBindingsFromPost( $postedBindings ): array { $bindings = []; $error = ''; if ($postedBindings === null || $postedBindings === '') { return [ 'bindings' => [], 'error' => '', ]; } if (!is_array($postedBindings)) { return [ 'bindings' => [], 'error' => 'Storylet Bindings must be a list.', ]; } $selectors = gameplayStoryletBindingSelectorOptions(); $seen = []; foreach ( array_values($postedBindings) as $index => $row ) { if (!is_array($row)) { continue; } $key = gameplayStoryletBindingNormalizeKey( (string)( $row['key'] ?? '' ) ); $label = trim( (string)( $row['label'] ?? '' ) ); $selector = strtolower( trim( (string)( $row['selector'] ?? '' ) ) ); /* * Ignore a completely blank newly-added row. */ if ( $key === '' && $label === '' && $selector === '' ) { continue; } $rowNumber = $index + 1; if ( $key === '' || preg_match( '/^[a-z][a-z0-9_]{0,31}$/', $key ) !== 1 ) { $error = 'Binding ' . $rowNumber . ' needs a Key beginning with a letter and containing only lowercase letters, numbers, and underscores (maximum 32 characters).'; break; } if (isset($seen[$key])) { $error = 'Binding Keys must be unique. "' . $key . '" is used more than once.'; break; } if ($label === '') { $error = 'Binding ' . $rowNumber . ' needs a Binding Name.'; break; } $labelLength = function_exists('mb_strlen') ? mb_strlen( $label, 'UTF-8' ) : strlen($label); if ($labelLength > 80) { $error = 'Binding ' . $rowNumber . ' Binding Name must be 80 characters or fewer.'; break; } if (!isset($selectors[$selector])) { $error = 'Binding ' . $rowNumber . ' must use a valid Selection Rule.'; break; } $seen[$key] = true; $bindings[] = [ 'key' => $key, 'label' => $label, 'selector' => $selector, ]; } return [ 'bindings' => $bindings, 'error' => $error, ]; } function gameplayStoryletBindingTraitCatalog( PDO $pdo, int $gameId, string $entityType ): array { $entityType = strtolower( trim($entityType) ); if ( $gameId <= 0 || !in_array( $entityType, ['character', 'item'], true ) ) { return []; } $stmt = $pdo->prepare( " SELECT id, config FROM wf_entities WHERE game_id = ? AND status = 'active' AND LOWER(entity_type) = ? ORDER BY id " ); $stmt->execute([ $gameId, $entityType, ]); $catalog = []; foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $config = json_decode( (string)( $row['config'] ?? '' ), true ); if (!is_array($config)) { $config = []; } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || ( array_key_exists( 'active', $trait ) && !$trait['active'] ) || !empty($trait['system']) ) { continue; } $name = trim( (string)( $trait['name'] ?? '' ) ); if ($name === '') { continue; } $type = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); if ($type === 'binary') { $type = 'boolean'; } if ( !in_array( $type, ['number', 'boolean'], true ) ) { continue; } $normalizedName = gameplayStoryletBindingNormalizeTraitName( $name ); if ($normalizedName === '') { continue; } if (!isset($catalog[$normalizedName])) { $catalog[$normalizedName] = [ 'name' => $name, 'data_type' => $type, 'conflict' => false, ]; continue; } if ( $catalog[$normalizedName]['data_type'] !== $type ) { /* * A random binding cannot safely target one logical Trait name * when different Entities define it with incompatible types. */ $catalog[$normalizedName]['conflict'] = true; } } } foreach ($catalog as $name => $definition) { if (!empty($definition['conflict'])) { unset($catalog[$name]); } } uasort( $catalog, static function ( array $a, array $b ): int { return strcasecmp( (string)$a['name'], (string)$b['name'] ); } ); return $catalog; } function gameplayStoryletBindingEffectTargets( PDO $pdo, int $gameId, array $bindings ): array { $targets = []; /* WAYFARER_PARTY_BINDINGS_V85 */ $partyTraits = function_exists( 'gameplayPartyTraitCatalog' ) ? gameplayPartyTraitCatalog( $pdo, $gameId ) : gameplayStoryletBindingTraitCatalog( $pdo, $gameId, 'character' ); $itemTraits = gameplayStoryletBindingTraitCatalog( $pdo, $gameId, 'item' ); foreach ($bindings as $binding) { if (!is_array($binding)) { continue; } $key = gameplayStoryletBindingNormalizeKey( (string)( $binding['key'] ?? '' ) ); $label = trim( (string)( $binding['label'] ?? $key ) ); $selector = strtolower( trim( (string)( $binding['selector'] ?? '' ) ) ); if ( $key === '' || preg_match( '/^[a-z][a-z0-9_]{0,31}$/', $key ) !== 1 ) { continue; } $scope = 'binding:' . $key; $group = 'Binding — ' . ( $label !== '' ? $label : $key ); if ($selector === 'random_party_character') { $targetId = 'sys_character_party_member'; $targetKey = $scope . ':trait:' . $targetId; $targets[$targetKey] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $targetId, 'target_system_key' => 'party_member', 'trait_type' => 'boolean', 'data_type' => 'boolean', 'semantic_type' => 'storylet_binding_trait', 'condition_operators' => [ 'equals', 'not_equals', ], 'effect_operations' => [ 'set', ], 'supports_random' => false, 'group' => $group, 'label' => 'Party Membership', 'helper' => 'Random Party Member', /* Legacy Entity-only property: keep saved Effects editable. */ 'effect_hidden' => true, 'hierarchy' => [ [ 'key' => 'storylet_binding', 'label' => 'Storylet Binding', ], [ 'key' => $scope, 'label' => $label !== '' ? $label : $key, ], [ 'key' => 'party_membership', 'label' => 'Party Membership', ], ], ]; foreach ( $partyTraits as $trait ) { $namedTargetId = gameplayStoryletBindingNamedTraitId( (string)$trait['name'] ); $targetKey = $scope . ':trait:' . $namedTargetId; $dataType = (string)$trait['data_type']; $targets[$targetKey] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $namedTargetId, 'trait_type' => $dataType, 'data_type' => $dataType, 'semantic_type' => 'storylet_binding_named_trait', 'condition_operators' => $dataType === 'boolean' ? [ 'equals', 'not_equals', ] : wayfarerConditionOperators(), 'effect_operations' => $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ], 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => (string)$trait['name'], 'helper' => 'Random Party Member', 'hierarchy' => [ [ 'key' => 'storylet_binding', 'label' => 'Storylet Binding', ], [ 'key' => $scope, 'label' => $label !== '' ? $label : $key, ], [ 'key' => $namedTargetId, 'label' => (string)$trait['name'], ], ], ]; } continue; } if ($selector === 'random_possessed_item') { $targetId = 'sys_item_quantity'; $targetKey = $scope . ':trait:' . $targetId; $targets[$targetKey] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $targetId, 'target_system_key' => 'quantity', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'storylet_binding_trait', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => [ 'set', 'add', 'subtract', ], 'supports_random' => true, 'group' => $group, 'label' => 'Quantity', 'helper' => 'Random Possessed Item', 'hierarchy' => [ [ 'key' => 'storylet_binding', 'label' => 'Storylet Binding', ], [ 'key' => $scope, 'label' => $label !== '' ? $label : $key, ], [ 'key' => 'quantity', 'label' => 'Quantity', ], ], ]; foreach ( $itemTraits as $trait ) { $namedTargetId = gameplayStoryletBindingNamedTraitId( (string)$trait['name'] ); $targetKey = $scope . ':trait:' . $namedTargetId; $dataType = (string)$trait['data_type']; $targets[$targetKey] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $namedTargetId, 'trait_type' => $dataType, 'data_type' => $dataType, 'semantic_type' => 'storylet_binding_named_trait', 'condition_operators' => $dataType === 'boolean' ? [ 'equals', 'not_equals', ] : wayfarerConditionOperators(), 'effect_operations' => $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ], 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => (string)$trait['name'], 'helper' => 'Random Possessed Item', 'hierarchy' => [ [ 'key' => 'storylet_binding', 'label' => 'Storylet Binding', ], [ 'key' => $scope, 'label' => $label !== '' ? $label : $key, ], [ 'key' => $namedTargetId, 'label' => (string)$trait['name'], ], ], ]; } } } return $targets; } ================================================================================ FILE: _wayfarer_backups/v87-20260929-192300/play/includes/action-screen.php ================================================================================ 0) { $actionRegionStmt = $pdo->prepare( " SELECT id, name, navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $actionRegionStmt->execute([ $actionRegionId, (int)$gameId, ]); $actionRegion = $actionRegionStmt->fetch( PDO::FETCH_ASSOC ); if ($actionRegion) { $actionRegionNavigationType = (string)( $actionRegion['navigation_type'] ?? '' ); $actionContextName = trim( (string)( $actionRegion['name'] ?? '' ) ) ?: 'Actions'; } } $actionLocation = wayfarerRuntimeFindCurrentLocation( $pdo, (int)$gameId, $state ); if ($actionLocation) { $locationName = trim( (string)( $actionLocation['name'] ?? '' ) ); if ($locationName !== '') { $actionContextName = $locationName; } } $actionCanTravel = strtolower( trim( $actionRegionNavigationType ) ) === 'linear'; /* WAYFARER_STORYLET_ACTION_SCREEN_V59 */ $actionStorylets = wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$gameId, $state ); /* * WAYFARER_ENTITY_PRESENTATION_V49 * * The V48 card switcher remains generic. V49 supplies the Inventory / Status * view models from canonical runtime Entity state. */ $actionEntityPresentation = wayfarerRuntimeBuildEntityPresentation( $pdo, (int)$gameId, $state, is_array($gameConfig ?? null) ? $gameConfig : [] ); $actionInventory = $actionEntityPresentation['inventory'] ?? []; $actionParty = $actionEntityPresentation['party'] ?? []; /* WAYFARER_PLAYER_TRAITS_STATUS_V52 */ $actionPlayerPresentation = wayfarerRuntimeBuildPlayerPresentation( is_array($gameConfig ?? null) ? $gameConfig : [], $state ); $actionPlayers = $actionPlayerPresentation['players'] ?? []; $actionGameStatusTraits = $actionPlayerPresentation['game_status_traits'] ?? []; /* * WAYFARER_STATUS_TRAIT_DISPLAY_V86 * * Status Traits may render as a Number, a Bar, or Both. Runtime presentation * metadata already falls back to Number when a bar cannot be calculated. */ $renderStatusTrait = static function ( array $trait ): void { $name = trim( (string)( $trait['name'] ?? '' ) ); $value = (string)( $trait['value'] ?? '' ); $mode = strtolower( trim( (string)( $trait['display_mode'] ?? 'number' ) ) ); if ( !in_array( $mode, ['number', 'bar', 'both'], true ) ) { $mode = 'number'; } $showBar = !empty( $trait['bar_available'] ) && is_numeric( $trait['bar_percent'] ?? null ) && ( $mode === 'bar' || $mode === 'both' ); if (!$showBar && $mode !== 'number') { $mode = 'number'; } $showValue = $mode === 'number' || $mode === 'both'; $percent = $showBar ? max( 0, min( 100, (float)$trait['bar_percent'] ) ) : 0; $minimum = $trait['bar_min'] ?? 0; $maximum = $trait['bar_max'] ?? 100; $current = $trait['bar_value'] ?? 0; ?>

>

What would you like to do?

No actions are currently available.

================================================================================ FILE: _wayfarer_backups/v87-20260929-192300/play/includes/effects.php ================================================================================ true, 'value' => wayfarerNormalizeRuntimeNumber( (float)$effectValue ), 'error' => '', ]; } return [ 'applied' => true, 'value' => $effectValue, 'error' => '', ]; } if ( $operation !== 'add' && $operation !== 'subtract' ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Unsupported operation: ' . $operation, ]; } if ( !is_numeric($currentValue) || !is_numeric($effectValue) ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Add/subtract requires numeric values.', ]; } $current = (float)$currentValue; $amount = (float)$effectValue; $result = $operation === 'add' ? $current + $amount : $current - $amount; return [ 'applied' => true, 'value' => wayfarerNormalizeRuntimeNumber( $result ), 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Tick Effect branch runtime |-------------------------------------------------------------------------- | | A Tick Effect owns one target and an ordered list of result branches. | Conditions are evaluated against the CURRENT runtime state. Because Tick | Effects execute sequentially, a later Tick Effect can see state changes made | by an earlier Tick Effect during the same tick. | */ function wayfarerRuntimeResolveConditionTarget( array $runtimeState, int $currentRegionId, string $scope, string $targetType, string $targetId ): array { $scope = strtolower( trim( $scope ) ); $targetType = strtolower( trim( $targetType ) ); $targetId = trim( $targetId ); // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $targetType === 'trait' && $targetId !== '' ) { $traits = $runtimeState['game']['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // SPECIFIC REGION TRAIT // WAYFARER_SPECIFIC_REGION_CONDITION_SCOPE_V46 // ------------------------------------------------------------ // // Game-level condition editors store a Region-owned Trait as // "region:". This lets a global rule inspect that Region whether // or not the player is currently there. // if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^region:([1-9][0-9]*)$/', $scope, $regionScopeMatch ) === 1 ) { $regionKey = (string)( (int)$regionScopeMatch[1] ); $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $targetType === 'trait' && $targetId !== '' && $currentRegionId > 0 ) { $regionKey = (string)$currentRegionId; $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // PLAYER / LOCATION / ENTITY TRAITS // WAYFARER_RUNTIME_UNIVERSAL_TARGET_RESOLVER_V56 // ------------------------------------------------------------ if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^player:([0-9]+)$/', $scope, $playerScopeMatch ) === 1 ) { $playerKey = (string)((int)$playerScopeMatch[1]); $traits = $runtimeState['players'][$playerKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^location:([1-9][0-9]*)$/', $scope, $locationScopeMatch ) === 1 ) { $locationKey = (string)((int)$locationScopeMatch[1]); $traits = $runtimeState['locations'][$locationKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityScopeMatch ) === 1 ) { $entityKey = (string)((int)$entityScopeMatch[1]); $traits = $runtimeState['entities'][$entityKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // GAME TIME // WAYFARER_HIERARCHICAL_TARGETS_V80 // ------------------------------------------------------------ if ( $scope === 'time' && $targetType === 'clock' ) { $clock = $runtimeState['runtime']['clock'] ?? null; if (!is_array($clock)) { return [ 'found' => false, 'value' => null, ]; } if ( $targetId === 'day' && array_key_exists( 'day', $clock ) ) { return [ 'found' => true, 'value' => (int)$clock['day'], ]; } if ( $targetId === 'hour' && array_key_exists( 'hour', $clock ) ) { return [ 'found' => true, 'value' => (int)$clock['hour'], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // REGION-QUALIFIED NAVIGATION POSITION // WAYFARER_LOCATION_CONDITION_TARGETS_V75 // ------------------------------------------------------------ if ( $targetType === 'position' && $targetId === 'position' && preg_match( '/^navigation:([1-9][0-9]*)$/', $scope, $navigationRegionMatch ) === 1 ) { $requiredRegionId = (int)$navigationRegionMatch[1]; if ( $requiredRegionId !== $currentRegionId ) { return [ 'found' => false, 'value' => null, ]; } if ( isset( $runtimeState['navigation'] ) && is_array( $runtimeState['navigation'] ) && array_key_exists( 'position', $runtimeState['navigation'] ) ) { return [ 'found' => true, 'value' => $runtimeState['navigation']['position'], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // LIVE LOCATION ANCHOR // WAYFARER_LIVE_LOCATION_ANCHORS_V76 // ------------------------------------------------------------ if ( $scope === 'navigation' && $targetType === 'location' && preg_match( '/^[1-9][0-9]*$/', $targetId ) === 1 ) { $locationKey = (string)((int)$targetId); $anchor = $runtimeState['locations'][$locationKey] ?? null; if (!is_array($anchor)) { return [ 'found' => false, 'value' => null, ]; } $anchorRegionId = (int)( $anchor['region_id'] ?? 0 ); $anchorPosition = $anchor['position'] ?? null; if ( $anchorRegionId <= 0 || !is_numeric($anchorPosition) ) { return [ 'found' => false, 'value' => null, ]; } /* * Before/After has no meaning across Regions. "Not at" is handled by * the Condition engine via comparison_scope_match=false. */ if ($anchorRegionId !== $currentRegionId) { return [ 'found' => true, 'value' => null, 'comparison_scope_match' => false, ]; } $currentPosition = $runtimeState['navigation']['position'] ?? null; if (!is_numeric($currentPosition)) { return [ 'found' => false, 'value' => null, ]; } return [ 'found' => true, 'value' => wayfarerNormalizeRuntimeNumber( (float)$currentPosition - (float)$anchorPosition ), 'comparison_scope_match' => true, ]; } // ------------------------------------------------------------ // NAMED LOCATION = CURRENT REGION + CURRENT NUMERIC POSITION // WAYFARER_LOCATION_CONDITION_TARGETS_V75 // ------------------------------------------------------------ if ( $scope === 'navigation' && $targetType === 'location' && $targetId === 'location' ) { if ( $currentRegionId <= 0 || !isset( $runtimeState['navigation'] ) || !is_array( $runtimeState['navigation'] ) || !array_key_exists( 'position', $runtimeState['navigation'] ) || !is_numeric( $runtimeState['navigation']['position'] ) ) { return [ 'found' => false, 'value' => null, ]; } $position = wayfarerNormalizeRuntimeNumber( (float)$runtimeState[ 'navigation' ]['position'] ); return [ 'found' => true, 'value' => $currentRegionId . ':' . (string)$position, ]; } // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $targetType === 'position' && $targetId === 'position' ) { if ( isset( $runtimeState['navigation'] ) && is_array( $runtimeState['navigation'] ) && array_key_exists( 'position', $runtimeState['navigation'] ) ) { return [ 'found' => true, 'value' => $runtimeState['navigation']['position'], ]; } return [ 'found' => false, 'value' => null, ]; } return [ 'found' => false, 'value' => null, ]; } function wayfarerRuntimeTickEffectBranches( array $effect ): array { /* * v28+ canonical structure. * * If the branches key exists, it is authoritative. An empty branches * array therefore means "this effect has no executable results" rather * than silently falling back to legacy compatibility fields. */ if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * Legacy pre-v28 structure. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'conditions', $effect ) ) { $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $effect['operation'] ?? '', 'value_mode' => $effect['value_mode'] ?? 'fixed', ]; if ( strtolower( trim( (string)$branch['value_mode'] ) ) === 'random' ) { $branch['value_min'] = $effect['value_min'] ?? 0; $branch['value_max'] = $effect['value_max'] ?? 0; } else { $branch['value'] = $effect['value'] ?? 0; } return [ $branch ]; } return []; } function wayfarerRuntimeSelectTickEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeTickEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Tick Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; /* * Validate before evaluating. Invalid authored data fails closed and * does NOT fall through to OTHERWISE, because doing so could silently * produce the wrong gameplay result. */ $validation = wayfarerConditionValidateNode( $conditions ); if (!$validation['valid']) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Tick Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Runtime Trait bounds |-------------------------------------------------------------------------- | | WAYFARER_RUNTIME_TRAIT_BOUNDS_V30 | | Authored numeric Trait min/max values are runtime invariants. | State is normalized before condition evaluation and every subsequent | mutation is clamped again before it is written. | */ function wayfarerRuntimeDecodeTraitConfig( $rawConfig ): array { if (is_array($rawConfig)) { return $rawConfig; } if ( $rawConfig === null || $rawConfig === '' ) { return []; } $decoded = json_decode( (string)$rawConfig, true ); return is_array($decoded) ? $decoded : []; } function wayfarerRuntimeIndexTraitDefinitions( array $traits ): array { $definitions = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $definitions[$traitId] = $trait; } return $definitions; } function wayfarerRuntimeLoadTickTraitDefinitions( PDO $pdo, int $gameId, array $regionConfig ): array { $gameConfig = []; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawGameConfig = $stmt->fetchColumn(); if ($rawGameConfig !== false) { $gameConfig = wayfarerRuntimeDecodeTraitConfig( $rawGameConfig ); } } /* WAYFARER_RUNTIME_UNIVERSAL_DEFINITIONS_V56 */ $definitions = [ 'game' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['traits'] ?? null ) ? $gameConfig['traits'] : [] ), 'player' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['player_traits'] ?? null ) ? $gameConfig['player_traits'] : [] ), 'region' => wayfarerRuntimeIndexTraitDefinitions( is_array( $regionConfig['traits'] ?? null ) ? $regionConfig['traits'] : [] ), 'regions' => [], 'locations' => [], 'entities' => [], ]; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $definitions['regions'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } $stmt = $pdo->prepare( " SELECT l.id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $definitions['locations'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } $stmt = $pdo->prepare( " SELECT id, entity_type, config FROM wf_entities WHERE game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); if ( function_exists( 'wayfarerEntityActiveTraitDefinitions' ) ) { $definitions['entities'][$id] = wayfarerEntityActiveTraitDefinitions( $config, (string)( $row['entity_type'] ?? '' ) ); } else { $definitions['entities'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } } } return $definitions; } function wayfarerRuntimeClampValueToTraitDefinition( $value, ?array $definition ): array { $result = [ 'valid' => true, 'value' => $value, 'clamped' => false, 'min' => null, 'max' => null, 'error' => '', ]; /* * Bounds currently belong only to authored Number Traits. */ if ( !$definition || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' ) { return $result; } if (!is_numeric($value)) { $result['valid'] = false; $result['error'] = 'Numeric Trait runtime state must be numeric.'; return $result; } $min = isset($definition['min']) && $definition['min'] !== '' && is_numeric($definition['min']) ? (float)$definition['min'] : null; $max = isset($definition['max']) && $definition['max'] !== '' && is_numeric($definition['max']) ? (float)$definition['max'] : null; $numeric = (float)$value; $bounded = $numeric; if ( $min !== null && $bounded < $min ) { $bounded = $min; } if ( $max !== null && $bounded > $max ) { $bounded = $max; } $result['value'] = wayfarerNormalizeRuntimeNumber( $bounded ); $result['clamped'] = $bounded !== $numeric; $result['min'] = $min === null ? null : wayfarerNormalizeRuntimeNumber( $min ); $result['max'] = $max === null ? null : wayfarerNormalizeRuntimeNumber( $max ); return $result; } function wayfarerRuntimeApplyBoundedTraitOperation( $storedBefore, string $operation, $effectValue, ?array $definition ): array { /* * Repair an already-illegal persisted value BEFORE applying the new * operation. Example: 120 with max 100 and subtract 10 becomes * 100 - 10 = 90, not 120 - 10 = 110 -> 100. */ $beforeBound = wayfarerRuntimeClampValueToTraitDefinition( $storedBefore, $definition ); if (!$beforeBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $storedBefore, 'requested_after' => $storedBefore, 'after' => $storedBefore, 'clamped' => false, 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $beforeBound['error'], ]; } $normalizedBefore = $beforeBound['value']; $operationResult = wayfarerApplyRuntimeOperation( $normalizedBefore, $operation, $effectValue ); if (!$operationResult['applied']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $normalizedBefore, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $operationResult['error'], ]; } $requestedAfter = $operationResult['value']; $afterBound = wayfarerRuntimeClampValueToTraitDefinition( $requestedAfter, $definition ); if (!$afterBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $afterBound['error'], ]; } return [ 'applied' => true, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $afterBound['value'], 'clamped' => $beforeBound['clamped'] || $afterBound['clamped'], 'min' => $afterBound['min'], 'max' => $afterBound['max'], 'error' => '', ]; } function wayfarerRuntimeClampApplicableTraitState( array &$runtimeState, array $traitDefinitions, int $currentRegionId ): array { $repairs = []; // ------------------------------------------------------------ // GAME TRAITS // ------------------------------------------------------------ $gameTraits = $runtimeState['game']['traits'] ?? null; if (is_array($gameTraits)) { foreach ( $traitDefinitions['game'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $gameTraits ) ) { continue; } $before = $runtimeState['game']['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['game']['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } // ------------------------------------------------------------ // CURRENT REGION TRAITS // ------------------------------------------------------------ $regionKey = (string)$currentRegionId; $regionTraits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if (is_array($regionTraits)) { foreach ( $traitDefinitions['region'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $regionTraits ) ) { continue; } $before = $runtimeState['regions'][$regionKey]['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } return $repairs; } function wayfarerApplyRuntimeTickEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectTickEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection['branch_index'], 'applied' => false, 'error' => $selection['error'], ]; } /* * No conditional branch matched and there was no OTHERWISE. * This is a valid "do nothing this tick" result. */ if (!$selection['matched']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection['branch_index']; $operation = strtolower( trim( (string)( $branch['operation'] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; /* * Explicit No Change: * the branch DID match, so this Tick Effect stops here, but state does * not mutate. */ if ($operation === 'none') { $current = wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $type, $targetId ); return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'amount' => null, 'before' => $current['found'] ? $current['value'] : null, 'after' => $current['found'] ? $current['value'] : null, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'applied' => false, 'no_change' => true, 'error' => '', ]; } /* WAYFARER_TICK_EFFECT_RANDOM_RUNTIME_V58 */ $resolvedValue = wayfarerRuntimeRollEffectValue( $branch ); if (!$resolvedValue['valid']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'applied' => false, 'error' => $resolvedValue['error'], ]; } /* * WAYFARER_BIDIRECTIONAL_TRAVEL_TICK_EFFECT_V71 * * Tick movement is relative to the player's current journey direction. * Only Tick Effects are reversed here. One-time narrative Effects remain * literal state changes, so an authored "Subtract 3 position" effect does * not unexpectedly change meaning because the player is traveling back. */ if ( $scope === 'navigation' && $type === 'position' && $targetId === 'position' && wayfarerRuntimeNavigationDirection( $runtimeState ) < 0 ) { if ($operation === 'add') { $operation = 'subtract'; } elseif ($operation === 'subtract') { $operation = 'add'; } } /* * Apply ONLY the selected result branch. The legacy effect-level * operation/value fields are deliberately ignored when branches exist. */ $resolvedEffect = $effect; $resolvedEffect['operation'] = $operation; $resolvedEffect['value'] = $resolvedValue['value']; $report = wayfarerApplyRuntimeEffect( $runtimeState, $resolvedEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; $report['value_mode'] = $resolvedValue['mode']; $report['rolled_value'] = $resolvedValue['value']; if ($resolvedValue['mode'] === 'random') { $report['random_min'] = $resolvedValue['min']; $report['random_max'] = $resolvedValue['max']; } return $report; } /* |-------------------------------------------------------------------------- | Party aggregate Effect helpers | WAYFARER_PARTY_TARGETS_RUNTIME_V85 |-------------------------------------------------------------------------- */ function wayfarerRuntimePartyNormalizeTraitName( string $value ): string { $value = trim( preg_replace( '/\\s+/u', ' ', $value ) ?? $value ); if (function_exists('mb_strtolower')) { return mb_strtolower( $value, 'UTF-8' ); } return strtolower($value); } function wayfarerRuntimePartyDecodeTraitTargetId( string $targetId ): ?string { $prefix = 'party_name_'; if ( strpos( $targetId, $prefix ) !== 0 ) { return null; } $hex = substr( $targetId, strlen($prefix) ); if ( $hex === '' || strlen($hex) % 2 !== 0 || preg_match( '/^[0-9a-f]+$/i', $hex ) !== 1 ) { return null; } $decoded = hex2bin($hex); return $decoded === false ? null : wayfarerRuntimePartyNormalizeTraitName( $decoded ); } function wayfarerRuntimePartyFindTraitDefinition( array $definitions, string $normalizedName ): ?array { $match = null; foreach ( $definitions as $definitionKey => $definition ) { if (!is_array($definition)) { continue; } $name = wayfarerRuntimePartyNormalizeTraitName( (string)( $definition['name'] ?? '' ) ); if ($name !== $normalizedName) { continue; } if ($match !== null) { return null; } $traitId = trim( (string)( $definition['_runtime_id'] ?? $definition['id'] ?? $definitionKey ) ); if ($traitId === '') { return null; } $match = [ 'id' => $traitId, 'definition' => $definition, ]; } return $match; } function wayfarerApplyRuntimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); $operation = strtolower( trim( (string)( $effect['operation'] ?? '' ) ) ); $effectValue = $effect['value'] ?? 0; // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $type === 'position' && $targetId === 'position' ) { if ( !isset($runtimeState['navigation']) || !is_array($runtimeState['navigation']) ) { $runtimeState['navigation'] = []; } $before = $runtimeState['navigation']['position'] ?? 0; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); $after = $before; if ( $result['applied'] && !is_numeric($result['value']) ) { $result['applied'] = false; $result['error'] = 'Navigation position must be numeric.'; } if ($result['applied']) { /* * Global navigation rule: * position may never be less than zero. */ $after = max( 0, $result['value'] ); $runtimeState['navigation']['position'] = $after; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $after, 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['game']) || !is_array($runtimeState['game']) ) { $runtimeState['game'] = []; } if ( !isset($runtimeState['game']['traits']) || !is_array($runtimeState['game']['traits']) ) { $runtimeState['game']['traits'] = []; } $before = $runtimeState['game']['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['game'][$targetId] ) && is_array( $traitDefinitions['game'][$targetId] ) ? $traitDefinitions['game'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); /* * Persist the normalized value even when a malformed operation fails * after an existing illegal value was repaired. */ if ( $result['normalized_before'] !== $before ) { $runtimeState['game']['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['game']['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // ALL PARTY MEMBERS - LOGICAL TRAIT BY NAME // WAYFARER_PARTY_TARGETS_RUNTIME_V85 // ------------------------------------------------------------ if ( $scope === 'party' && $type === 'trait' && $targetId !== '' ) { $normalizedName = wayfarerRuntimePartyDecodeTraitTargetId( $targetId ); if ($normalizedName === null) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Party Trait target is invalid.', ]; } $results = []; $eligibleCount = 0; $partyCount = 0; $firstError = ''; $players = isset($runtimeState['players']) && is_array($runtimeState['players']) ? $runtimeState['players'] : []; $playerDefinitions = isset($traitDefinitions['player']) && is_array($traitDefinitions['player']) ? $traitDefinitions['player'] : []; $playerTrait = wayfarerRuntimePartyFindTraitDefinition( $playerDefinitions, $normalizedName ); foreach ($players as $playerKey => $playerState) { if (!is_array($playerState)) { continue; } $name = trim( (string)( $playerState['name'] ?? '' ) ); if ($name === '') { continue; } $partyCount++; if (!is_array($playerTrait)) { continue; } $actualTargetId = (string)$playerTrait['id']; $runtimeTraits = isset($playerState['traits']) && is_array($playerState['traits']) ? $playerState['traits'] : []; if ( !array_key_exists( $actualTargetId, $runtimeTraits ) ) { continue; } $eligibleCount++; $memberScope = 'player:' . (string)$playerKey; $childEffect = [ 'target_scope' => $memberScope, 'target_type' => 'trait', 'target_id' => $actualTargetId, 'operation' => $operation, 'value' => $effectValue, ]; $child = wayfarerApplyRuntimeEffect( $runtimeState, $childEffect, $currentRegionId, $traitDefinitions ); $child['party_member_name'] = $name; $child['party_member_scope'] = $memberScope; $results[] = $child; if (!empty($child['error'])) { $firstError = (string)$child['error']; break; } } if ($firstError === '') { $entities = isset($runtimeState['entities']) && is_array($runtimeState['entities']) ? $runtimeState['entities'] : []; foreach ($entities as $entityKey => $entityState) { if (!is_array($entityState)) { continue; } $runtimeTraits = isset($entityState['traits']) && is_array($entityState['traits']) ? $entityState['traits'] : []; if ( empty( $runtimeTraits[ 'sys_character_party_member' ] ) ) { continue; } $partyCount++; $definitions = isset( $traitDefinitions['entities'][ (string)$entityKey ] ) && is_array( $traitDefinitions['entities'][ (string)$entityKey ] ) ? $traitDefinitions['entities'][ (string)$entityKey ] : []; $entityTrait = wayfarerRuntimePartyFindTraitDefinition( $definitions, $normalizedName ); if (!is_array($entityTrait)) { continue; } $actualTargetId = (string)$entityTrait['id']; if ( !array_key_exists( $actualTargetId, $runtimeTraits ) ) { continue; } $eligibleCount++; $memberScope = 'entity:' . (string)$entityKey; $childEffect = [ 'target_scope' => $memberScope, 'target_type' => 'trait', 'target_id' => $actualTargetId, 'operation' => $operation, 'value' => $effectValue, ]; $child = wayfarerApplyRuntimeEffect( $runtimeState, $childEffect, $currentRegionId, $traitDefinitions ); $child['party_member_scope'] = $memberScope; $results[] = $child; if (!empty($child['error'])) { $firstError = (string)$child['error']; break; } } } if ( $firstError === '' && $eligibleCount === 0 ) { $firstError = 'No current party member exposes the selected Trait.'; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'party_count' => $partyCount, 'eligible_count' => $eligibleCount, 'skipped_count' => max( 0, $partyCount - $eligibleCount ), 'results' => $results, 'applied' => $firstError === '' && $eligibleCount > 0, 'error' => $firstError, ]; } // ------------------------------------------------------------ // ITEM ENTITY QUANTITY // WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 // ------------------------------------------------------------ if ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityScopeMatch ) === 1 && ( (string)( $effect['target_system_key'] ?? '' ) === 'quantity' || $targetId === 'sys_item_quantity' ) ) { $entityKey = (string)( (int)$entityScopeMatch[1] ); if ( !isset( $runtimeState['entities'][$entityKey] ) || !is_array( $runtimeState['entities'][$entityKey] ) || !isset( $runtimeState['entities'][$entityKey]['traits'] ) || !is_array( $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Entity runtime state was not initialized.', ]; } if ( !array_key_exists( $targetId, $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Quantity runtime state was not found.', ]; } $before = $runtimeState['entities'][$entityKey]['traits'][$targetId]; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); if ( !$result['applied'] || !is_numeric( $result['value'] ?? null ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $before, 'applied' => false, 'error' => $result['error'] !== '' ? $result['error'] : 'Item Quantity must remain numeric.', ]; } $requestedAfter = wayfarerNormalizeRuntimeNumber( (float)$result['value'] ); /* * Quantity is globally non-negative. */ $after = wayfarerNormalizeRuntimeNumber( max( 0.0, (float)$requestedAfter ) ); $runtimeState['entities'][$entityKey]['traits'][$targetId] = $after; /* * Discovery is persistent. Receiving any positive starting quantity * discovers the Item; setting it back to zero never undiscoveres it. */ if ( (float)$after > 0 ) { $runtimeState['entities'][$entityKey]['discovered'] = true; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_system_key' => 'quantity', 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'requested_after' => $requestedAfter, 'after' => $after, 'clamped' => (float)$after !== (float)$requestedAfter, 'min' => 0, 'max' => null, 'applied' => true, 'error' => '', ]; } // ------------------------------------------------------------ // SPECIFIC PLAYER / REGION / LOCATION / ENTITY TRAITS // WAYFARER_RUNTIME_UNIVERSAL_EFFECT_TARGETS_V56 // ------------------------------------------------------------ $stateBucket = null; $stateKey = null; $definition = null; if ( $type === 'trait' && $targetId !== '' && preg_match( '/^player:([0-9]+)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'players'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['player'][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^region:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'regions'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['regions'][$stateKey][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^location:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'locations'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['locations'][$stateKey][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 && $targetId !== 'sys_item_quantity' ) { $stateBucket = 'entities'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['entities'][$stateKey][$targetId] ?? null; } if ( $stateBucket !== null && $stateKey !== null ) { if ( !isset($runtimeState[$stateBucket][$stateKey]) || !is_array($runtimeState[$stateBucket][$stateKey]) || !isset($runtimeState[$stateBucket][$stateKey]['traits']) || !is_array($runtimeState[$stateBucket][$stateKey]['traits']) || !array_key_exists( $targetId, $runtimeState[$stateBucket][$stateKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Runtime Trait target was not found.', ]; } $before = $runtimeState[$stateBucket][$stateKey]['traits'][$targetId]; /* * Party Membership is mutable runtime state, but still a binary * Wayfarer system property. The 0/1 rule is enforced internally and is * never exposed as a separate Target/max metadata value. */ if ( $stateBucket === 'entities' && $targetId === 'sys_character_party_member' ) { $operationResult = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); if ( !$operationResult['applied'] || !is_numeric( $operationResult['value'] ?? null ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $before, 'applied' => false, 'error' => $operationResult['error'] !== '' ? $operationResult['error'] : 'Party Membership requires a numeric 0/1 result.', ]; } $after = (float)$operationResult['value'] > 0 ? 1 : 0; $runtimeState[$stateBucket][$stateKey]['traits'][$targetId] = $after; return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_system_key' => 'party_member', 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $after, 'applied' => true, 'error' => '', ]; } $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, is_array($definition) ? $definition : null ); if ($result['applied']) { $runtimeState[$stateBucket][$stateKey]['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // PLAYER TRAIT // WAYFARER_STARTING_CHOICE_PLAYER_TARGETS_V53 // ------------------------------------------------------------ if ( $scope === 'player' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['players']) || !is_array($runtimeState['players']) ) { $runtimeState['players'] = []; } $selector = strtolower( trim( (string)( $effect['target_selector'] ?? '' ) ) ); $targetKeys = []; if ($selector === 'all') { foreach ( $runtimeState['players'] as $playerKey => $playerState ) { if (is_array($playerState)) { $targetKeys[] = (string)$playerKey; } } } elseif ( $selector === 'slot' && array_key_exists( 'target_slot', $effect ) ) { $slot = max( 0, (int)$effect['target_slot'] ); $targetKeys[] = (string)$slot; } else { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect requires an All Players or Player Slot target.', ]; } if (empty($targetKeys)) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect did not resolve any Player Characters.', ]; } $definition = isset( $traitDefinitions['player'][$targetId] ) && is_array( $traitDefinitions['player'][$targetId] ) ? $traitDefinitions['player'][$targetId] : null; $results = []; $firstError = ''; foreach ($targetKeys as $playerKey) { if ( !isset( $runtimeState['players'][$playerKey] ) || !is_array( $runtimeState['players'][$playerKey] ) ) { $firstError = 'Player Character slot ' . ((int)$playerKey + 1) . ' does not exist in runtime state.'; $results[$playerKey] = [ 'applied' => false, 'error' => $firstError, ]; break; } if ( !isset( $runtimeState['players'][$playerKey]['traits'] ) || !is_array( $runtimeState['players'][$playerKey]['traits'] ) ) { $runtimeState['players'][$playerKey]['traits'] = []; } $before = $runtimeState['players'][$playerKey]['traits'][$targetId] ?? 0; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['after']; } $results[$playerKey] = [ 'slot' => (int)$playerKey, 'name' => (string)( $runtimeState['players'][$playerKey]['name'] ?? ( 'Player ' . ((int)$playerKey + 1) ) ), 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; if ( !$result['applied'] || $result['error'] !== '' ) { $firstError = $result['error'] !== '' ? $result['error'] : 'Player Trait effect could not be applied.'; break; } } $allApplied = $firstError === ''; return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_selector' => $selector, 'target_slot' => $selector === 'slot' ? (int)( $effect['target_slot'] ?? 0 ) : null, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'results' => $results, 'applied' => $allApplied, 'error' => $firstError, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $type === 'trait' && $targetId !== '' ) { $regionKey = (string)$currentRegionId; if ( !isset($runtimeState['regions']) || !is_array($runtimeState['regions']) ) { $runtimeState['regions'] = []; } if ( !isset($runtimeState['regions'][$regionKey]) || !is_array($runtimeState['regions'][$regionKey]) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset($runtimeState['regions'][$regionKey]['traits']) || !is_array($runtimeState['regions'][$regionKey]['traits']) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } $before = $runtimeState['regions'][$regionKey]['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['region'][$targetId] ) && is_array( $traitDefinitions['region'][$targetId] ) ? $traitDefinitions['region'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Unsupported runtime target.', ]; } /* |-------------------------------------------------------------------------- | Game Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_GAME_TICK_EFFECT_RUNTIME_V33 | | Game Tick Effects execute once on every successful runtime tick before | the active Region's Tick Effects. This creates a deterministic layering: | | Game rules -> Region rules | */ function wayfarerApplyGameTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { if ($gameId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active game is available for Game Tick Effects.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { $reports = [ [ 'applied' => false, 'error' => 'Game ' . $gameId . ' was not found.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $gameConfig = []; if ( $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, [] ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $gameEffects = $gameConfig['tick_effects'] ?? []; if (!is_array($gameEffects)) { $gameEffects = []; } $reports = []; foreach ($gameEffects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Game Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; $runtimeState['runtime']['last_game_trait_bound_repairs'] = $boundRepairs; return $reports; } /* |-------------------------------------------------------------------------- | Generalized one-time Effect runtime | WAYFARER_GENERALIZED_EFFECT_RUNTIME_V43 |-------------------------------------------------------------------------- | | Conditions use the same shared evaluator as Tick Effect branches. | Random values are resolved exactly once, immediately before the selected | result mutates runtime state. | */ function wayfarerRuntimeEffectBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * v42 compatibility: one Effect was one unconditional operation/value. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $branch = [ 'conditions' => null, 'operation' => $effect['operation'] ?? 'add', 'value_mode' => $effect['value_mode'] ?? 'fixed', ]; if ( strtolower( trim( (string)( $branch[ 'value_mode' ] ) ) ) === 'random' ) { $branch['value_min'] = $effect['value_min'] ?? 0; $branch['value_max'] = $effect['value_max'] ?? 0; } else { $branch['value'] = $effect['value'] ?? 0; } return [ $branch ]; } return []; } function wayfarerRuntimeSelectEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; $validation = wayfarerConditionValidateNode( $conditions ); if ( !$validation['valid'] ) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } function wayfarerRuntimeRollEffectValue( array $branch ): array { $mode = strtolower( trim( (string)( $branch[ 'value_mode' ] ?? 'fixed' ) ) ); if ($mode !== 'random') { $value = $branch['value'] ?? null; /* WAYFARER_FIXED_SCALAR_EFFECT_RUNTIME_V58 */ return [ 'valid' => true, 'mode' => 'fixed', 'value' => is_numeric($value) ? wayfarerNormalizeRuntimeNumber( (float)$value ) : $value, 'min' => null, 'max' => null, 'error' => '', ]; } $minimum = $branch['value_min'] ?? null; $maximum = $branch['value_max'] ?? null; if ( !is_numeric($minimum) || !is_numeric($maximum) ) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => $minimum, 'max' => $maximum, 'error' => 'Random Effect requires numeric Min and Max values.', ]; } $min = (float)$minimum; $max = (float)$maximum; if ($min > $max) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => 'Random Effect Min cannot be greater than Max.', ]; } if ( floor($min) === $min && floor($max) === $max ) { $rolled = random_int( (int)$min, (int)$max ); } else { /* * Continuous decimal range. Six decimal places of entropy are more * than sufficient for authored simulation values and preserve generic * numeric Min/Max support without requiring authored precision metadata. */ $fraction = random_int( 0, 1000000 ) / 1000000; $rolled = $min + (($max - $min) * $fraction); } return [ 'valid' => true, 'mode' => 'random', 'value' => wayfarerNormalizeRuntimeNumber( (float)$rolled ), 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => '', ]; } function wayfarerApplyRuntimeOneTimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect[ 'target_scope' ] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect[ 'target_type' ] ?? '' ) ) ); $targetId = trim( (string)( $effect[ 'target_id' ] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection[ 'branch_index' ], 'applied' => false, 'error' => $selection[ 'error' ], ]; } if ( !$selection['matched'] ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection[ 'branch_index' ]; $operation = strtolower( trim( (string)( $branch[ 'operation' ] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; if ($operation === 'none') { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => null, 'rolled_value' => null, 'applied' => false, 'error' => '', ]; } $resolved = wayfarerRuntimeRollEffectValue( $branch ); if (!$resolved['valid']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => $resolved[ 'mode' ], 'rolled_value' => null, 'random_min' => $resolved[ 'min' ], 'random_max' => $resolved[ 'max' ], 'applied' => false, 'error' => $resolved[ 'error' ], ]; } $concreteEffect = [ 'target_scope' => $scope, 'target_type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'value' => $resolved[ 'value' ], ]; $report = wayfarerApplyRuntimeEffect( $runtimeState, $concreteEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; $report['value_mode'] = $resolved[ 'mode' ]; $report['rolled_value'] = $resolved[ 'value' ]; if ( $resolved['mode'] === 'random' ) { $report['random_min'] = $resolved[ 'min' ]; $report['random_max'] = $resolved[ 'max' ]; } return $report; } /* |-------------------------------------------------------------------------- | One-time Effect player feedback | WAYFARER_ONE_TIME_EFFECT_FEEDBACK_V70 |-------------------------------------------------------------------------- | | Change events are derived from the Effect report AFTER runtime mutation. | Therefore Random rolls and min/max/non-negative clamps are already reflected | in before/after, and the player sees the quantity that truly changed. | | This layer is intentionally called only by narrative one-time Effect paths. | Tick Effects remain silent. | */ function wayfarerRuntimeFeedbackFindTrait( array $config, string $targetId, string $collection = 'traits' ): ?array { $traits = isset($config[$collection]) && is_array($config[$collection]) ? $config[$collection] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } $id = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($id === $targetId) { return $trait; } } return null; } function wayfarerRuntimeFeedbackTraitIsPlayerFacing(array $trait): bool { $feedback = strtolower( trim( (string)( $trait['feedback'] ?? '' ) ) ); if ($feedback === 'hide') { return false; } if ($feedback === 'show') { return true; } $display = isset($trait['display']) && is_array($trait['display']) ? $trait['display'] : []; $surfaces = isset($display['surfaces']) && is_array($display['surfaces']) ? $display['surfaces'] : []; return !empty($surfaces); } function wayfarerRuntimeFeedbackTraitMetadata( array $trait, string $fallbackLabel, string $ownerLabel = '' ): ?array { if (!wayfarerRuntimeFeedbackTraitIsPlayerFacing($trait)) { return null; } $label = trim( (string)( $trait['name'] ?? $fallbackLabel ) ); if ($label === '') { $label = $fallbackLabel; } $type = strtolower( trim( (string)( $trait['type'] ?? 'number' ) ) ); if ($type === 'binary') { $type = 'boolean'; } return [ 'kind' => 'trait', 'label' => $label, 'owner_label' => $ownerLabel, 'data_type' => $type, ]; } function wayfarerRuntimeFeedbackTargetMetadata( PDO $pdo, int $gameId, array $runtimeState, array $report ): ?array { $scope = strtolower( trim( (string)( $report['scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $report['type'] ?? '' ) ) ); $targetId = trim( (string)( $report['target_id'] ?? '' ) ); if ( $scope === '' || $targetId === '' || $type !== 'trait' ) { return null; } /* Navigation is deliberately silent even for one-time Effects. */ if ($scope === 'navigation') { return null; } /* -------------------------------------------------------------- * Entity state: Quantity and Party Membership are intrinsically visible. * ----------------------------------------------------------- */ if ( preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityMatch ) === 1 ) { $entityId = (int)$entityMatch[1]; $stmt = $pdo->prepare( " SELECT name, entity_type, config FROM wf_entities WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $entityId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $entityName = trim( (string)( $row['name'] ?? ('Entity ' . $entityId) ) ); if ($entityName === '') { $entityName = 'Entity ' . $entityId; } $systemKey = strtolower( trim( (string)( $report['target_system_key'] ?? '' ) ) ); if ( $targetId === 'sys_item_quantity' || $systemKey === 'quantity' ) { return [ 'kind' => 'item_quantity', 'label' => $entityName, 'owner_label' => $entityName, 'data_type' => 'number', ]; } if ( $targetId === 'sys_character_party_member' || $systemKey === 'party_member' ) { return [ 'kind' => 'party_membership', 'label' => $entityName, 'owner_label' => $entityName, 'data_type' => 'boolean', ]; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, $entityName ) : null; } /* -------------------------------------------------------------- * Game Trait. * ----------------------------------------------------------- */ if ($scope === 'game') { $stmt = $pdo->prepare( 'SELECT config FROM wf_games WHERE id = ? LIMIT 1' ); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); if ($raw === false) { return null; } $config = wayfarerRuntimeDecodeTraitConfig($raw); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, 'Game' ) : null; } /* -------------------------------------------------------------- * Specific Region Trait, plus legacy current-Region compatibility. * ----------------------------------------------------------- */ $regionId = 0; if ( preg_match( '/^region:([1-9][0-9]*)$/', $scope, $regionMatch ) === 1 ) { $regionId = (int)$regionMatch[1]; } elseif ($scope === 'region') { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); } if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, trim((string)($row['name'] ?? 'Region')) ) : null; } /* -------------------------------------------------------------- * Location Trait. * ----------------------------------------------------------- */ if ( preg_match( '/^location:([1-9][0-9]*)$/', $scope, $locationMatch ) === 1 ) { $locationId = (int)$locationMatch[1]; $stmt = $pdo->prepare( " SELECT l.name, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE l.id = ? AND r.game_id = ? LIMIT 1 " ); $stmt->execute([ $locationId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, trim((string)($row['name'] ?? 'Location')) ) : null; } /* -------------------------------------------------------------- * Specific Player Character Trait. * ----------------------------------------------------------- */ if ( preg_match( '/^player:([0-9]+)$/', $scope, $playerMatch ) === 1 ) { $slot = (int)$playerMatch[1]; $stmt = $pdo->prepare( 'SELECT config FROM wf_games WHERE id = ? LIMIT 1' ); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); if ($raw === false) { return null; } $config = wayfarerRuntimeDecodeTraitConfig($raw); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId, 'player_traits' ); if (!is_array($trait)) { return null; } $playerName = trim( (string)( $runtimeState['players'][(string)$slot]['name'] ?? ('Player ' . ($slot + 1)) ) ); return wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, $playerName ); } return null; } function wayfarerRuntimeBuildEffectChangeEvents( PDO $pdo, int $gameId, array $runtimeState, array $reports ): array { $changes = []; foreach ($reports as $report) { if ( !is_array($report) || empty($report['applied']) ) { continue; } /* WAYFARER_PARTY_TARGETS_RUNTIME_V85 */ if ( strtolower( trim( (string)( $report['scope'] ?? '' ) ) ) === 'party' && isset($report['results']) && is_array($report['results']) ) { foreach ( wayfarerRuntimeBuildEffectChangeEvents( $pdo, $gameId, $runtimeState, $report['results'] ) as $partyChange ) { $changes[] = $partyChange; } continue; } $metadata = wayfarerRuntimeFeedbackTargetMetadata( $pdo, $gameId, $runtimeState, $report ); if (!is_array($metadata)) { continue; } $before = $report['before'] ?? null; $after = $report['after'] ?? null; $kind = (string)($metadata['kind'] ?? 'trait'); $label = trim((string)($metadata['label'] ?? '')); $ownerLabel = trim((string)($metadata['owner_label'] ?? '')); if ($label === '') { continue; } if ($kind === 'party_membership') { if (!is_numeric($before) || !is_numeric($after)) { continue; } $beforeMember = (float)$before > 0; $afterMember = (float)$after > 0; if ($beforeMember === $afterMember) { continue; } $joined = $afterMember; $changes[] = [ 'kind' => 'party_membership', 'direction' => $joined ? 'gain' : 'loss', 'label' => $label, 'before' => $beforeMember ? 1 : 0, 'after' => $afterMember ? 1 : 0, 'delta' => $joined ? 1 : -1, 'text' => $label . ($joined ? ' joined your party.' : ' left your party.'), 'aria_text' => $label . ($joined ? ' joined your party.' : ' left your party.'), ]; continue; } $dataType = strtolower( trim((string)($metadata['data_type'] ?? 'number')) ); if ($dataType === 'boolean') { if (!is_numeric($before) || !is_numeric($after)) { continue; } $beforeBool = (float)$before > 0; $afterBool = (float)$after > 0; if ($beforeBool === $afterBool) { continue; } $displayLabel = $ownerLabel !== '' && $ownerLabel !== $label ? $label . ' — ' . $ownerLabel : $label; $changes[] = [ 'kind' => $kind, 'direction' => $afterBool ? 'gain' : 'loss', 'label' => $label, 'owner_label' => $ownerLabel, 'before' => $beforeBool ? 1 : 0, 'after' => $afterBool ? 1 : 0, 'delta' => $afterBool ? 1 : -1, 'text' => $displayLabel . ': ' . ($afterBool ? 'On' : 'Off'), 'aria_text' => ($ownerLabel !== '' && $ownerLabel !== $label ? $ownerLabel . ': ' . $label : $label) . ' is now ' . ($afterBool ? 'on.' : 'off.'), ]; continue; } if (!is_numeric($before) || !is_numeric($after)) { continue; } $deltaFloat = (float)$after - (float)$before; if (abs($deltaFloat) < 0.000000000001) { continue; } $delta = wayfarerNormalizeRuntimeNumber($deltaFloat); $amount = wayfarerNormalizeRuntimeNumber(abs($deltaFloat)); $gain = $deltaFloat > 0; $displayLabel = $ownerLabel !== '' && $ownerLabel !== $label && $kind !== 'item_quantity' ? $label . ' — ' . $ownerLabel : $label; $text = ($gain ? '+' : '-') . (string)$amount . ' ' . $displayLabel; if ($kind === 'item_quantity') { $ariaText = ($gain ? 'Gained ' : 'Lost ') . (string)$amount . ' ' . $label . '.'; } else { $ariaSubject = $ownerLabel !== '' && $ownerLabel !== $label ? $ownerLabel . ': ' . $label : $label; $ariaText = $ariaSubject . ($gain ? ' increased by ' : ' decreased by ') . (string)$amount . '.'; } $changes[] = [ 'kind' => $kind, 'direction' => $gain ? 'gain' : 'loss', 'label' => $label, 'owner_label' => $ownerLabel, 'before' => $before, 'after' => $after, 'delta' => $delta, 'text' => $text, 'aria_text' => $ariaText, ]; } return $changes; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECT_RUNTIME_V42 | | These are event Effects, not Tick Effects. The caller decides that a | Location has been triggered and invokes this function exactly once for that | trigger. Effects execute top-to-bottom against current runtime state. | */ function wayfarerApplyLocationEffects( PDO $pdo, int $gameId, array $location, array &$runtimeState ): array { $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeTraitConfig( $location['config'] ?? null ); $effects = $locationConfig['effects'] ?? []; if (!is_array($effects)) { $effects = []; } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false ) { $regionConfig = wayfarerRuntimeDecodeTraitConfig( $rawRegionConfig ); } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeOneTimeEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_location_effects'] = [ 'location_id' => $locationId, 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), 'reports' => $reports, /* WAYFARER_LOCATION_EFFECT_FEEDBACK_V70 */ 'changes' => wayfarerRuntimeBuildEffectChangeEvents( $pdo, $gameId, $runtimeState, $reports ), 'bound_repairs' => $boundRepairs, ]; return $reports; } /* |-------------------------------------------------------------------------- | Current Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECT_RUNTIME_V35 | | Linear Regions resolve a current Location by exact authored Position, | matching the player-scene resolver. Location Tick Effects run after Region | Tick Effects, so movement performed by the Region can cause arrival effects | at the newly reached Location on the same tick. | */ function wayfarerRuntimeFindCurrentLocation( PDO $pdo, int $gameId, array $runtimeState ): ?array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ( $gameId <= 0 || $regionId <= 0 ) { return null; } $position = $runtimeState['navigation']['position'] ?? null; if (!is_numeric($position)) { return null; } $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($locations as $location) { $config = []; if ( !empty( $location['config'] ) ) { $decoded = json_decode( (string)$location['config'], true ); if (is_array($decoded)) { $config = $decoded; } } if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } if ( (float)$config['position'] === (float)$position ) { $location['_runtime_config'] = $config; /* * WAYFARER_LOCATION_CONDITIONS_CURRENT_V45 */ if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } return $location; } } return null; } function wayfarerApplyCurrentLocationTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } /* * Being between authored Locations is normal. Record an empty result * rather than treating the absence of a Location as an error. */ if (!$location) { $runtimeState['runtime']['last_location_id'] = null; $runtimeState['runtime']['last_location_tick_effects'] = []; return []; } $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : []; $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); /* * Location effects may target Current Region Traits, so load the active * Region's Trait definitions rather than the Location's authored Traits. */ $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false && $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $locationEffects = $locationConfig['tick_effects'] ?? []; if (!is_array($locationEffects)) { $locationEffects = []; } $reports = []; foreach ( $locationEffects as $effect ) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } $runtimeState['runtime']['last_location_id'] = $locationId; $runtimeState['runtime']['last_location_tick_effects'] = $reports; $runtimeState['runtime']['last_location_trait_bound_repairs'] = $boundRepairs; return $reports; } /* * WAYFARER_GAME_TRAVEL_MODIFIERS_RUNTIME_V73 * * Game Travel Modifiers are evaluated only when dedicated Linear Travel is * about to move the player. They do not mutate state by themselves and are * intentionally silent in normal Effect feedback. */ function wayfarerRuntimeEvaluateGameTravelModifiers( PDO $pdo, int $gameId, int $currentRegionId, array &$runtimeState ): array { $result = [ 'adjustment' => 0, 'matched' => [], ]; if ($gameId <= 0) { return $result; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig === false || $rawConfig === null || $rawConfig === '' ) { return $result; } $gameConfig = json_decode( (string)$rawConfig, true ); if (!is_array($gameConfig)) { return $result; } $modifiers = $gameConfig['travel']['modifiers'] ?? []; if (!is_array($modifiers)) { return $result; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; $adjustment = 0.0; foreach ( array_values($modifiers) as $modifier ) { if (!is_array($modifier)) { continue; } $operation = strtolower( trim( (string)( $modifier['operation'] ?? '' ) ) ); $value = $modifier['value'] ?? null; if ( !in_array( $operation, ['add', 'subtract'], true ) || !is_numeric($value) || (float)$value <= 0 ) { continue; } if ( !wayfarerConditionsPass( $modifier['conditions'] ?? null, $resolver ) ) { continue; } $delta = $operation === 'subtract' ? -(float)$value : (float)$value; $adjustment += $delta; $result['matched'][] = [ 'id' => (string)( $modifier['id'] ?? '' ), 'name' => (string)( $modifier['name'] ?? 'Travel Modifier' ), 'operation' => $operation, 'value' => wayfarerNormalizeRuntimeNumber( (float)$value ), 'adjustment' => wayfarerNormalizeRuntimeNumber( $delta ), ]; } $result['adjustment'] = wayfarerNormalizeRuntimeNumber( $adjustment ); return $result; } function wayfarerApplyCurrentRegionTickEffects( PDO $pdo, array &$runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ($regionId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active navigation region.', ] ]; /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Reports are returned for the current request, not persisted. */ return $reports; } $stmt = $pdo->prepare( " SELECT id, game_id, config FROM wf_regions WHERE id = ? LIMIT 1 " ); $stmt->execute([ $regionId ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { $reports = [ [ 'applied' => false, 'error' => 'Active Region ' . $regionId . ' was not found.', ] ]; /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Reports are returned for the current request, not persisted. */ return $reports; } $config = []; if (!empty($region['config'])) { $decoded = json_decode( $region['config'], true ); if (is_array($decoded)) { $config = $decoded; } } /* WAYFARER_LINEAR_TRAVEL_TICK_PIPELINE_V72 */ $dedicatedTravel = wayfarerRuntimeDedicatedLinearTravel( $config ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, (int)( $region['game_id'] ?? 0 ), $config ); /* * Enforce the invariant before any branch condition is evaluated. * This also repairs state produced by older runtime versions. */ $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $regionId ); $effects = $config['tick_effects'] ?? []; if (!is_array($effects)) { $effects = []; } $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed tick effect.', ]; continue; } /* * WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 * * A dedicated travel Region owns movement through config.travel. * Ignore only Region Tick Effects that target Navigation Position; * every other recurring Region Effect continues normally. */ if ( $dedicatedTravel !== null && strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ) === 'navigation' && strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ) === 'position' && (string)( $effect['target_id'] ?? '' ) === 'position' ) { $reports[] = [ 'scope' => 'navigation', 'type' => 'position', 'target_id' => 'position', 'applied' => false, 'ignored' => true, 'reason' => 'Dedicated Linear Travel owns Region movement.', 'error' => '', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $regionId, $traitDefinitions ); } /* WAYFARER_GAME_TRAVEL_MODIFIERS_RUNTIME_V73 */ if ($dedicatedTravel !== null) { $gameTravelModifierResult = wayfarerRuntimeEvaluateGameTravelModifiers( $pdo, (int)( $region['game_id'] ?? 0 ), $regionId, $runtimeState ); $reports[] = wayfarerRuntimeApplyDedicatedLinearTravelMovement( $runtimeState, $dedicatedTravel, (float)( $gameTravelModifierResult[ 'adjustment' ] ?? 0 ), $gameTravelModifierResult[ 'matched' ] ?? [] ); } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Tick reports and bound-repair reports are immediate diagnostics only. */ return $reports; } ================================================================================ FILE: _wayfarer_backups/v87-20260929-192300/play/includes/runtime.php ================================================================================ $mode, 'starting_day' => max(1, (int)($raw['starting_day'] ?? 1)), 'ticks_per_day' => max(1, (int)($raw['ticks_per_day'] ?? 1)), 'starting_hour' => max(0, (int)($raw['starting_hour'] ?? 8)), 'ticks_per_hour' => max(1, (int)($raw['ticks_per_hour'] ?? 1)), 'hours_per_day' => max(1, (int)($raw['hours_per_day'] ?? 24)), ]; if ($config['starting_hour'] >= $config['hours_per_day']) { $config['starting_hour'] = $config['hours_per_day'] - 1; } return $config; } function wayfarerRuntimeInitializeClockFromConfig( array $gameConfig, array &$runtimeState ): void { if (!isset($runtimeState['runtime']) || !is_array($runtimeState['runtime'])) { $runtimeState['runtime'] = []; } $config = wayfarerRuntimeTimeConfig($gameConfig); if ($config['mode'] === 'none') { unset($runtimeState['runtime']['clock']); return; } $clock = [ 'mode' => $config['mode'], 'day' => $config['starting_day'], ]; if ($config['mode'] === 'days') { $clock['tick_in_day'] = 0; } else { $clock['hour'] = $config['starting_hour']; $clock['tick_in_hour'] = 0; } $runtimeState['runtime']['clock'] = $clock; } function wayfarerRuntimeLoadGameConfig(PDO $pdo, int $gameId): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare('SELECT config FROM wf_games WHERE id = ? LIMIT 1'); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); return wayfarerRuntimeDecodeConfig($raw === false ? null : $raw); } function wayfarerRuntimeEnsureClock( PDO $pdo, int $gameId, array &$runtimeState ): array { $gameConfig = wayfarerRuntimeLoadGameConfig($pdo, $gameId); $timeConfig = wayfarerRuntimeTimeConfig($gameConfig); $clock = $runtimeState['runtime']['clock'] ?? null; if ($timeConfig['mode'] === 'none') { unset($runtimeState['runtime']['clock']); return $timeConfig; } if (!is_array($clock) || (string)($clock['mode'] ?? '') !== $timeConfig['mode']) { wayfarerRuntimeInitializeClockFromConfig($gameConfig, $runtimeState); } return $timeConfig; } function wayfarerRuntimeAdvanceClock( PDO $pdo, int $gameId, array &$runtimeState ): void { $config = wayfarerRuntimeEnsureClock($pdo, $gameId, $runtimeState); if ($config['mode'] === 'none') { return; } $clock =& $runtimeState['runtime']['clock']; $clock['day'] = max(1, (int)($clock['day'] ?? $config['starting_day'])); if ($config['mode'] === 'days') { $clock['tick_in_day'] = max(0, (int)($clock['tick_in_day'] ?? 0)) + 1; if ($clock['tick_in_day'] >= $config['ticks_per_day']) { $clock['day']++; $clock['tick_in_day'] = 0; } return; } $clock['hour'] = max(0, (int)($clock['hour'] ?? $config['starting_hour'])); $clock['tick_in_hour'] = max(0, (int)($clock['tick_in_hour'] ?? 0)) + 1; if ($clock['tick_in_hour'] >= $config['ticks_per_hour']) { $clock['tick_in_hour'] = 0; $clock['hour']++; if ($clock['hour'] >= $config['hours_per_day']) { $clock['hour'] = 0; $clock['day']++; } } } function wayfarerRuntimeClockLabel(array $runtimeState, array $timeConfig): string { if ($timeConfig['mode'] === 'none') { return ''; } $clock = $runtimeState['runtime']['clock'] ?? []; $day = max(1, (int)($clock['day'] ?? $timeConfig['starting_day'])); if ($timeConfig['mode'] === 'days') { return 'Day ' . $day; } $hour = max(0, (int)($clock['hour'] ?? $timeConfig['starting_hour'])); if ($timeConfig['hours_per_day'] === 24) { $displayHour = $hour % 12; if ($displayHour === 0) { $displayHour = 12; } $suffix = $hour < 12 ? 'AM' : 'PM'; return 'Day ' . $day . ' · ' . $displayHour . ':00 ' . $suffix; } return 'Day ' . $day . ' · Hour ' . $hour; } /* * WAYFARER_BIDIRECTIONAL_TRAVEL_V71 * * Runtime direction is journey state, not Region authoring state. +1 follows * the Region's authored forward Tick Effect; -1 reverses it. Keeping the * authored Add/Subtract sign as the base preserves existing Regions while * allowing the same line to be traversed in either direction. */ function wayfarerRuntimeNavigationDirection(array $runtimeState): int { $direction = (int)( $runtimeState['navigation']['direction'] ?? 1 ); return $direction < 0 ? -1 : 1; } /* * WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 * * Returns canonical dedicated travel configuration, or null for Regions that * still use the legacy Position Tick Effect movement path. */ function wayfarerRuntimeDedicatedLinearTravel( array $regionConfig ): ?array { $travel = isset($regionConfig['travel']) && is_array($regionConfig['travel']) ? $regionConfig['travel'] : null; if ( $travel === null || empty($travel['enabled']) || !isset($travel['base_distance']) || !is_numeric($travel['base_distance']) || (float)$travel['base_distance'] <= 0 ) { return null; } return [ 'base_distance' => (float)$travel['base_distance'], 'allow_turnaround' => !empty( $travel['allow_turnaround'] ), ]; } /* WAYFARER_GAME_TRAVEL_MODIFIERS_MOVEMENT_V73 */ function wayfarerRuntimeApplyDedicatedLinearTravelMovement( array &$runtimeState, array $travel, float $modifierAdjustment = 0.0, array $matchedModifiers = [] ): array { if ( !isset($runtimeState['navigation']) || !is_array($runtimeState['navigation']) ) { $runtimeState['navigation'] = []; } $before = $runtimeState['navigation']['position'] ?? 0; if (!is_numeric($before)) { return [ 'scope' => 'navigation', 'type' => 'position', 'target_id' => 'position', 'applied' => false, 'error' => 'Navigation position must be numeric.', ]; } $baseDistance = max( 0.0, (float)( $travel['base_distance'] ?? 0 ) ); /* * Modifiers may slow travel to zero, but never reverse the player's * chosen direction. Direction is applied only after effective distance is * clamped to a non-negative value. */ $distance = max( 0.0, $baseDistance + $modifierAdjustment ); $direction = wayfarerRuntimeNavigationDirection( $runtimeState ); $after = max( 0.0, (float)$before + ($distance * $direction) ); $after = wayfarerNormalizeRuntimeNumber( $after ); $runtimeState['navigation']['position'] = $after; return [ 'scope' => 'navigation', 'type' => 'position', 'target_id' => 'position', 'operation' => 'travel', 'base_distance' => wayfarerNormalizeRuntimeNumber( $baseDistance ), 'modifier_adjustment' => wayfarerNormalizeRuntimeNumber( $modifierAdjustment ), 'matched_modifiers' => array_values( $matchedModifiers ), 'amount' => wayfarerNormalizeRuntimeNumber( $distance ), 'direction' => $direction, 'before' => wayfarerNormalizeRuntimeNumber( (float)$before ), 'after' => $after, 'applied' => true, 'error' => '', ]; } function wayfarerRuntimeLinearTravelDirection( array $regionConfig, array $runtimeState ): int { $runtimeDirection = wayfarerRuntimeNavigationDirection( $runtimeState ); /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ if ( wayfarerRuntimeDedicatedLinearTravel( $regionConfig ) !== null ) { return $runtimeDirection; } $effects = isset($regionConfig['tick_effects']) && is_array($regionConfig['tick_effects']) ? $regionConfig['tick_effects'] : []; foreach ($effects as $effect) { if (!is_array($effect)) { continue; } if ( (string)($effect['target_scope'] ?? '') !== 'navigation' || (string)($effect['target_type'] ?? '') !== 'position' || (string)($effect['target_id'] ?? '') !== 'position' ) { continue; } $branches = wayfarerRuntimeTickEffectBranches($effect); foreach ($branches as $branch) { $operation = strtolower(trim((string)($branch['operation'] ?? ''))); if ($operation === 'subtract') { return -1 * $runtimeDirection; } if ($operation === 'add') { return 1 * $runtimeDirection; } } } return $runtimeDirection; } function wayfarerRuntimeBuildTravelStatus( PDO $pdo, int $gameId, array &$runtimeState ): array { $gameConfig = wayfarerRuntimeLoadGameConfig($pdo, $gameId); $timeConfig = wayfarerRuntimeEnsureClock($pdo, $gameId, $runtimeState); $result = [ 'time_label' => wayfarerRuntimeClockLabel($runtimeState, $timeConfig), 'region_name' => '', 'distance_label' => 'units', 'next_location_id' => 0, 'next_location_name' => '', 'distance_to_next' => null, 'next_label' => '', /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ 'dedicated_travel' => false, 'base_distance_per_tick' => null, 'allow_turnaround' => false, 'can_turn_around' => false, 'direction' => 1, ]; $regionId = (int)($runtimeState['navigation']['region_id'] ?? 0); $position = $runtimeState['navigation']['position'] ?? null; if ($regionId <= 0 || !is_numeric($position)) { return $result; } $stmt = $pdo->prepare( 'SELECT id, name, navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1' ); $stmt->execute([$regionId, $gameId]); $region = $stmt->fetch(PDO::FETCH_ASSOC); if (!$region) { return $result; } $result['region_name'] = trim((string)($region['name'] ?? '')); if (strtolower(trim((string)($region['navigation_type'] ?? ''))) !== 'linear') { return $result; } $regionConfig = wayfarerRuntimeDecodeConfig($region['config'] ?? null); $distanceLabel = trim((string)($regionConfig['distance_label'] ?? 'units')); $result['distance_label'] = $distanceLabel !== '' ? $distanceLabel : 'units'; $direction = wayfarerRuntimeLinearTravelDirection($regionConfig, $runtimeState); $current = (float)$position; /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ $dedicatedTravel = wayfarerRuntimeDedicatedLinearTravel( $regionConfig ); $result['direction'] = $direction; if ($dedicatedTravel !== null) { $result['dedicated_travel'] = true; $result['base_distance_per_tick'] = wayfarerNormalizeRuntimeNumber( (float)$dedicatedTravel[ 'base_distance' ] ); $result['allow_turnaround'] = !empty( $dedicatedTravel[ 'allow_turnaround' ] ); /* * Do not offer a reversal that would immediately point out of the * legal lower boundary. If already traveling toward Start at zero, * turning around toward End remains valid. */ $result['can_turn_around'] = $result['allow_turnaround'] && !( $current <= 0 && $direction > 0 ); } $locationStmt = $pdo->prepare( "SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id" ); $locationStmt->execute([$regionId]); $best = null; $bestDistance = null; foreach ($locationStmt->fetchAll(PDO::FETCH_ASSOC) as $location) { $locationConfig = wayfarerRuntimeDecodeConfig($location['config'] ?? null); if (!isset($locationConfig['position']) || !is_numeric($locationConfig['position'])) { continue; } $locationPosition = (float)$locationConfig['position']; $ahead = $direction < 0 ? $locationPosition < $current : $locationPosition > $current; if (!$ahead) { continue; } $location['_runtime_config'] = $locationConfig; if (!wayfarerRuntimeLocationConditionsPass($pdo, $gameId, $location, $runtimeState)) { continue; } $distance = abs($locationPosition - $current); if ($best === null || $distance < $bestDistance) { $best = $location; $bestDistance = $distance; } } if ($best !== null) { $distance = wayfarerNormalizeRuntimeNumber((float)$bestDistance); $name = trim((string)($best['name'] ?? 'Location')) ?: 'Location'; $result['next_location_id'] = (int)($best['id'] ?? 0); $result['next_location_name'] = $name; $result['distance_to_next'] = $distance; $result['next_label'] = $name . ' — ' . $distance . ' ' . $result['distance_label']; } return $result; } function wayfarerRuntimeActiveTraitDefinitions( array $config ): array { $traits = $config['traits'] ?? []; if (!is_array($traits)) { return []; } $result = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( array_key_exists( 'active', $trait ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $trait['_runtime_id'] = $traitId; $result[] = $trait; } return $result; } function wayfarerRuntimeInitialTraitValue( array $trait ) { if ( array_key_exists( 'initial', $trait ) ) { return $trait['initial']; } $type = strtolower( trim( (string)( $trait['type'] ?? '' ) ) ); if ( $type === 'number' || $type === 'binary' || $type === 'boolean' ) { return 0; } return null; } function wayfarerRuntimeInitialTraitState( array $config ): array { $state = []; foreach ( wayfarerRuntimeActiveTraitDefinitions( $config ) as $trait ) { $traitId = (string)$trait['_runtime_id']; $state[$traitId] = wayfarerRuntimeInitialTraitValue( $trait ); } return $state; } /* |-------------------------------------------------------------------------- | Persisted state for every Region |-------------------------------------------------------------------------- | WAYFARER_ALL_REGION_STATE_V46 | | Game-wide Conditions may inspect a Region that the player has never visited. | Therefore every authored Region needs persistent Trait state, not only the | current Region. | | This helper is additive/recovery-safe: | - existing runtime values are never overwritten; | - missing Regions or newly-added Traits receive their authored initial; | - stale historical values are not destructively removed here. | */ function wayfarerRuntimeEnsureAllRegionTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['regions']) || !is_array($runtimeState['regions']) ) { $runtimeState['regions'] = []; } $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? ORDER BY id " ); $stmt->execute([ $gameId ]); $regions = $stmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($regions as $region) { $regionId = (int)( $region['id'] ?? 0 ); if ($regionId <= 0) { continue; } $regionKey = (string)$regionId; $config = wayfarerRuntimeDecodeConfig( $region['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset( $runtimeState['regions'][$regionKey] ) || !is_array( $runtimeState['regions'][$regionKey] ) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset( $runtimeState['regions'][$regionKey]['traits'] ) || !is_array( $runtimeState['regions'][$regionKey]['traits'] ) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['regions'][$regionKey]['traits'] ) ) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $initialValue; } } } /* |-------------------------------------------------------------------------- | Persisted state for every Location | WAYFARER_ALL_LOCATION_STATE_V56 |-------------------------------------------------------------------------- | | Locations can own Traits, so universal Conditions/Effects need persistent | per-playthrough Location Trait state just like Regions. | */ function wayfarerRuntimeEnsureAllLocationTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['locations']) || !is_array($runtimeState['locations']) ) { $runtimeState['locations'] = []; } $stmt = $pdo->prepare( " SELECT l.id, l.region_id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $location ) { $locationId = (int)($location['id'] ?? 0); if ($locationId <= 0) { continue; } $locationKey = (string)$locationId; $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset($runtimeState['locations'][$locationKey]) || !is_array($runtimeState['locations'][$locationKey]) ) { $runtimeState['locations'][$locationKey] = []; } /* * WAYFARER_LIVE_LOCATION_ANCHORS_V76 * * These are definition-derived spatial facts, so refresh them every * ensure pass. Moving a Location in Builder therefore changes the * anchor without rewriting authored Conditions. */ $runtimeState['locations'][$locationKey]['region_id'] = (int)( $location['region_id'] ?? 0 ); $runtimeState['locations'][$locationKey]['position'] = isset($config['position']) && is_numeric($config['position']) ? (float)$config['position'] : null; if ( !isset($runtimeState['locations'][$locationKey]['traits']) || !is_array($runtimeState['locations'][$locationKey]['traits']) ) { $runtimeState['locations'][$locationKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['locations'][$locationKey]['traits'] ) ) { continue; } $runtimeState['locations'][$locationKey]['traits'][$traitId] = $initialValue; } } } function wayfarerRuntimeLoadStartingRegion( PDO $pdo, int $gameId, ?array $gameConfig = null ): ?array { if ($gameId <= 0) { return null; } if ($gameConfig === null) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { return null; } $gameConfig = wayfarerRuntimeDecodeConfig( $rawConfig ); } $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT * FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $startingRegionId, $gameId, ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); return $region ?: null; } function wayfarerRuntimeInitializeGame( PDO $pdo, int $gameUserId, array $setupInput = [] ): array { if ($gameUserId <= 0) { throw new RuntimeException( 'A valid game-user ID is required to initialize the game.' ); } try { $pdo->beginTransaction(); /* * Lock the playthrough row so two initialize requests cannot create * competing starting states. */ $stmt = $pdo->prepare( " SELECT gu.id, gu.game_id, gu.user_id, gu.role, gu.state, g.config AS game_config, g.player_slots AS player_slots FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id WHERE gu.id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } /* * Initialization is intentionally one-way. To restart a development * game, clear wf_game_users.state to NULL first. */ if ( $row['state'] !== null && $row['state'] !== '' ) { throw new RuntimeException( 'This game-user already has runtime state.' ); } $gameId = (int)$row['game_id']; $gameConfig = wayfarerRuntimeDecodeConfig( $row['game_config'] ?? null ); $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { throw new RuntimeException( 'This Game does not have a Starting Region configured.' ); } $startingRegion = wayfarerRuntimeLoadStartingRegion( $pdo, $gameId, $gameConfig ); if (!$startingRegion) { throw new RuntimeException( 'The configured Starting Region does not exist for this Game.' ); } $regionConfig = wayfarerRuntimeDecodeConfig( $startingRegion['config'] ?? null ); $gameTraits = wayfarerRuntimeInitialTraitState( $gameConfig ); $regionTraits = wayfarerRuntimeInitialTraitState( $regionConfig ); $navigation = [ 'region_id' => (int)$startingRegion['id'], /* WAYFARER_BIDIRECTIONAL_TRAVEL_V71 */ 'direction' => 1, ]; /* * Linear travel currently begins at coordinate zero. * Other navigation modes will get their own authored start-state * rules when those modes are implemented. */ if ( strtolower( trim( (string)( $startingRegion['navigation_type'] ?? '' ) ) ) === 'linear' ) { $navigation['position'] = 0; } $regionKey = (string)$startingRegion['id']; $state = [ /* * WAYFARER_RUNTIME_SCHEMA_V55 * WAYFARER_BIDIRECTIONAL_TRAVEL_V71 * Version the persisted playthrough contract explicitly. */ 'schema_version' => 2, 'game' => [ 'traits' => $gameTraits, ], 'navigation' => $navigation, 'regions' => [ $regionKey => [ 'traits' => $regionTraits, ], ], 'runtime' => [ 'tick' => 0, ], ]; /* WAYFARER_INITIALIZE_GAME_CLOCK_V61 */ wayfarerRuntimeInitializeClockFromConfig( $gameConfig, $state ); /* * WAYFARER_ALL_REGION_STATE_V46 * * Global conditions can inspect Region state before that Region has * ever been visited. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $state ); /* WAYFARER_INITIALIZE_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_V49 * * Items and Characters receive canonical per-playthrough state before * any starting setup effects are applied. */ wayfarerRuntimeEnsureEntityState( $pdo, $gameId, $state ); /* * WAYFARER_APPLY_STARTING_CHOICES_V36 * * Validate all player-entered setup data before persisting anything. * Then apply the selected options' starting effects to the canonical * initial runtime state. */ $preparedSetup = wayfarerRuntimePrepareStartingSetup( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $setupInput ); /* * WAYFARER_PLAYER_TRAITS_RUNTIME_V52 * * Persist the validated player-character names before creating the * per-player Trait state. Each slot receives independent values. */ $state['setup'] = [ 'player_names' => $preparedSetup['player_names'], 'choices' => $preparedSetup['choices'], ]; wayfarerRuntimeEnsureGameTraitState( $gameConfig, $state ); wayfarerRuntimeEnsurePlayerState( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $state ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $startingEffectReports = []; foreach ( $preparedSetup['effects'] as $startingEffect ) { /* WAYFARER_STARTING_CHOICE_RANDOM_RUNTIME_V58 */ $resolvedStartingValue = wayfarerRuntimeRollEffectValue( $startingEffect ); if (!$resolvedStartingValue['valid']) { throw new RuntimeException( 'Unable to resolve a Starting Choice effect value: ' . $resolvedStartingValue['error'] ); } $concreteStartingEffect = $startingEffect; $concreteStartingEffect['value'] = $resolvedStartingValue['value']; $report = wayfarerApplyRuntimeEffect( $state, $concreteStartingEffect, (int)$startingRegion['id'], $traitDefinitions ); if ( !empty($report['error']) ) { throw new RuntimeException( 'Unable to apply a Starting Choice effect: ' . $report['error'] ); } $report['value_mode'] = $resolvedStartingValue['mode']; $report['rolled_value'] = $resolvedStartingValue['value']; if ($resolvedStartingValue['mode'] === 'random') { $report['random_min'] = $resolvedStartingValue['min']; $report['random_max'] = $resolvedStartingValue['max']; } $startingEffectReports[] = $report; } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * * Starting-effect reports are intentionally not persisted. The effects * themselves have already mutated canonical state. */ /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 * * A game that begins exactly on an active Location should present that * Location before ordinary Action Mode. */ $startingLocation = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $state ); if ($startingLocation) { /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Starting on a Location is a real Location trigger. Apply its * one-time Effects before opening Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, $gameId, $startingLocation, $state ); $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$startingLocation['id'], 'phase' => 'presentation', 'trigger' => 'initial', 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /* WAYFARER_GAME_ENDINGS_INITIAL_V45 */ wayfarerRuntimeEvaluateGameEndings( $pdo, $gameId, $state ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => $gameId, 'starting_region_id' => (int)$startingRegion['id'], 'state' => $state, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Resolve a game-user record for the tick prototype. * * Preferred: * explicit wf_game_users.id * * Temporary development fallback: * first wf_game_users record. * * The fallback exists only because Wayfarer has not yet wired the play * page to authentication / game selection. Once that exists, remove the * fallback and always pass the active game_user_id explicitly. */ function wayfarerRuntimeResolveGameUserId( PDO $pdo, ?int $requestedId = null ): int { if ($requestedId !== null && $requestedId > 0) { $stmt = $pdo->prepare( " SELECT id FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $requestedId ]); $id = $stmt->fetchColumn(); if ($id !== false) { return (int)$id; } throw new RuntimeException( 'The requested game-user record does not exist.' ); } $id = $pdo ->query( " SELECT id FROM wf_game_users ORDER BY id LIMIT 1 " ) ->fetchColumn(); if ($id === false) { throw new RuntimeException( 'No wf_game_users record exists.' ); } return (int)$id; } /** * Load one game-user runtime state. */ function wayfarerRuntimeLoad( PDO $pdo, int $gameUserId ): array { $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'state' => $state, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /** * Advance exactly one tick. * * This is intentionally the entire v14 game engine: * * state.runtime.tick += 1 * * The transaction and row lock are already in place so later work can add * movement/effects without changing the request architecture. */ /* |-------------------------------------------------------------------------- | Linear Location crossing | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | A tick may move farther than the distance to the next authored Location. | This resolver finds the FIRST active Location crossed in the direction of | travel. The movement is then clamped to that exact authored coordinate. | | Examples: | 45 -> 55, Location 50 => stop at 50 | 55 -> 45, Location 50 => stop at 50 | | The starting coordinate is intentionally exclusive so leaving a Location | does not immediately trigger that same Location again. | */ /* |-------------------------------------------------------------------------- | Location Conditions | WAYFARER_LOCATION_CONDITIONS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeLocationConditionsPass( PDO $pdo, int $gameId, array $location, array &$runtimeState ): bool { $config = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $conditions = $config['conditions'] ?? null; $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; return wayfarerConditionsPass( $conditions, $resolver ); } /* |-------------------------------------------------------------------------- | Game Endings | WAYFARER_GAME_ENDINGS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeEvaluateGameEndings( PDO $pdo, int $gameId, array &$runtimeState ): ?array { /* * WAYFARER_ALL_REGION_STATE_ENDINGS_V46 */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $runtimeState ); /* WAYFARER_RECOVER_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $runtimeState ); if ( isset( $runtimeState['runtime']['ending'] ) && is_array( $runtimeState['runtime']['ending'] ) ) { return $runtimeState['runtime']['ending']; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); $config = wayfarerRuntimeDecodeConfig( $raw === false ? null : $raw ); $endings = $config['endings'] ?? []; if (!is_array($endings)) { return null; } $ordered = []; foreach ( array_values($endings) as $index => $ending ) { if (!is_array($ending)) { continue; } $ending['_runtime_order'] = $index; $ordered[] = $ending; } usort( $ordered, function ( array $a, array $b ): int { $priorityCompare = (int)( $b['priority'] ?? 0 ) <=> (int)( $a['priority'] ?? 0 ); if ($priorityCompare !== 0) { return $priorityCompare; } return (int)( $a['_runtime_order'] ?? 0 ) <=> (int)( $b['_runtime_order'] ?? 0 ); } ); $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; foreach ($ordered as $ending) { if ( !wayfarerConditionsPass( $ending['conditions'] ?? null, $resolver ) ) { continue; } $terminal = [ 'id' => (string)( $ending['id'] ?? '' ), 'title' => (string)( $ending['title'] ?? 'The End' ), 'text' => (string)( $ending['text'] ?? '' ), 'image_media_id' => (int)( $ending['image_media_id'] ?? 0 ), 'priority' => (int)( $ending['priority'] ?? 0 ), 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), ]; if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['ending'] = $terminal; unset( $runtimeState['runtime']['presentation'] ); return $terminal; } return null; } function wayfarerRuntimeFindCrossedLocation( PDO $pdo, int $gameId, int $regionId, $fromPosition, $toPosition, array &$runtimeState ): ?array { /* * WAYFARER_CROSSED_LOCATION_STATE_FIX_V46_1 * * Location Conditions are evaluated against the live runtime state. */ if ( $gameId <= 0 || $regionId <= 0 || !is_numeric($fromPosition) || !is_numeric($toPosition) ) { return null; } $from = (float)$fromPosition; $to = (float)$toPosition; if ($from === $to) { return null; } /* * Crossing interception currently applies only to Linear Regions. */ $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); $forward = $to > $from; $candidate = null; $candidatePosition = null; foreach ($locations as $location) { $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $locationPosition = (float)$config['position']; /* * The starting point is exclusive; the destination is inclusive. */ $crossed = $forward ? ( $locationPosition > $from && $locationPosition <= $to ) : ( $locationPosition < $from && $locationPosition >= $to ); if (!$crossed) { continue; } $location['_runtime_config'] = $config; if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } if ($candidate === null) { $candidate = $location; $candidatePosition = $locationPosition; } else { $nearer = $forward ? ( $locationPosition < $candidatePosition ) : ( $locationPosition > $candidatePosition ); $samePositionLowerId = $locationPosition === $candidatePosition && (int)$location['id'] < (int)$candidate['id']; if ( $nearer || $samePositionLowerId ) { $candidate = $location; $candidatePosition = $locationPosition; } } } if ($candidate === null) { return null; } $candidate['_runtime_position'] = wayfarerNormalizeRuntimeNumber( (float)$candidatePosition ); $candidate['_runtime_config'] = wayfarerRuntimeDecodeConfig( $candidate['config'] ?? null ); return $candidate; } /* |-------------------------------------------------------------------------- | Player spatial context |-------------------------------------------------------------------------- | WAYFARER_PLAYER_CONTEXT_V46_1 | | This is the canonical player-facing spatial label for Action Mode: | | exact eligible Location -> Location name + Region parent context | otherwise -> Region name | | It is returned by runtime APIs so the browser does not keep displaying the | Location name from the page's original server render after movement. | */ function wayfarerRuntimePlayerContext( PDO $pdo, int $gameId, array $runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionName = ''; if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $rawRegionName = $stmt->fetchColumn(); if ($rawRegionName !== false) { $regionName = trim( (string)$rawRegionName ); } } $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); $locationId = 0; $locationName = ''; if ($location) { $locationId = (int)( $location['id'] ?? 0 ); $locationName = trim( (string)( $location['name'] ?? '' ) ); } $contextName = $locationName !== '' ? $locationName : ( $regionName !== '' ? $regionName : 'Actions' ); return [ 'region_id' => $regionId, 'region_name' => $regionName, 'location_id' => $locationId, 'location_name' => $locationName, 'context_name' => $contextName, 'parent_context' => $locationName !== '' ? $regionName : '', ]; } /* |-------------------------------------------------------------------------- | Presentation dismissal |-------------------------------------------------------------------------- | | Presentation is persisted runtime state. This function is intentionally | generic even though v40 renders only Location presentations; Storylets can | use the same mechanism later. | */ function wayfarerRuntimeDismissPresentation( PDO $pdo, int $gameUserId, string $expectedType = '', int $expectedId = 0 ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); $presentation = $state['runtime']['presentation'] ?? null; if ( !is_array($presentation) || empty($presentation['type']) || empty($presentation['id']) ) { throw new RuntimeException( 'There is no active presentation to continue.' ); } if ( $expectedType !== '' && (string)$presentation['type'] !== $expectedType ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } if ( $expectedId > 0 && (int)$presentation['id'] !== $expectedId ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } /* WAYFARER_STORYLET_BRANCH_DISMISS_GUARD_V59 */ if ( (string)($presentation['type'] ?? '') === 'storylet' ) { $eligibleBranches = wayfarerRuntimeEligibleStoryletBranches( $pdo, (int)$row['game_id'], (int)($presentation['id'] ?? 0), $state ); if ($eligibleBranches) { throw new RuntimeException( 'Choose an available Storylet Branch before continuing.' ); } } $state['runtime']['last_presentation'] = $presentation; unset( $state['runtime']['presentation'] ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'dismissed' => $presentation, /* WAYFARER_STORYLET_ACTIONS_AFTER_PRESENTATION_V59 */ 'actions' => wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$row['game_id'], $state ), ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Advance exactly one tick. * * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ function wayfarerRuntimeAdvanceTick( PDO $pdo, int $gameUserId, ?int $requestedTravelDirection = null ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } /* WAYFARER_BIDIRECTIONAL_TRAVEL_V71 */ if ( !isset($state['navigation']) || !is_array($state['navigation']) ) { $state['navigation'] = []; } if ( $requestedTravelDirection !== null && !in_array( $requestedTravelDirection, [-1, 1], true ) ) { throw new RuntimeException( 'Travel direction must be -1 or 1.' ); } $state['navigation']['direction'] = $requestedTravelDirection !== null ? $requestedTravelDirection : wayfarerRuntimeNavigationDirection( $state ); $state['schema_version'] = max( 2, (int)( $state['schema_version'] ?? 1 ) ); /* * WAYFARER_GAME_ENDINGS_TERMINAL_V45 * * Endings are terminal runtime state. */ if ( isset( $state['runtime']['ending'] ) && is_array( $state['runtime']['ending'] ) ) { throw new RuntimeException( 'This game has ended.' ); } /* * Narrative presentation is a true runtime pause, not merely a hidden * browser control. Even Author Mode cannot advance ticks through it. */ if ( isset( $state['runtime']['presentation'] ) && is_array( $state['runtime']['presentation'] ) ) { throw new RuntimeException( 'Continue the current presentation before advancing time.' ); } /* * WAYFARER_ALL_REGION_STATE_TICK_V46 * * Recovery path for playthroughs initialized before v46, and for * Regions/Traits authored after a playthrough began. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_LOCATION_STATE_TICK_RECOVERY_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, (int)$row['game_id'], $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_TICK_V49 * * Recovery path for playthroughs created before V49 and for Entities * or Traits authored after play began. */ wayfarerRuntimeEnsureEntityState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_PLAYER_TRAITS_TICK_RECOVERY_V52 */ wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$row['game_id'], $state ); $regionIdBeforeTick = (int)( $state['navigation']['region_id'] ?? 0 ); $positionBeforeTick = $state['navigation']['position'] ?? null; $currentTick = (int)( $state['runtime']['tick'] ?? 0 ); $state['runtime']['tick'] = $currentTick + 1; /* WAYFARER_ADVANCE_GAME_CLOCK_V61 */ wayfarerRuntimeAdvanceClock( $pdo, (int)$row['game_id'], $state ); /* * Global Game Tick Effects run first. */ wayfarerApplyGameTickEffects( $pdo, (int)$row['game_id'], $state ); /* * Current Region Tick Effects then perform Region-level movement and * state changes. */ wayfarerApplyCurrentRegionTickEffects( $pdo, $state ); /* * Resolve whether the completed movement would have crossed an active * Location. If so, stop at the nearest crossed Location rather than * accepting the overshot position. */ $regionIdAfterMovement = (int)( $state['navigation']['region_id'] ?? 0 ); $positionAfterMovement = $state['navigation']['position'] ?? null; $crossedLocation = null; /* * We only interpret movement as a continuous path while the player * remains in the same Region. Region transitions will later get their * own explicit navigation semantics. */ if ( $regionIdBeforeTick > 0 && $regionIdBeforeTick === $regionIdAfterMovement ) { $crossedLocation = wayfarerRuntimeFindCrossedLocation( $pdo, (int)$row['game_id'], $regionIdAfterMovement, $positionBeforeTick, $positionAfterMovement, $state ); } $arrivalPosition = null; if ($crossedLocation) { $arrivalPosition = $crossedLocation[ '_runtime_position' ]; $state['navigation']['position'] = $arrivalPosition; /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Event Effects execute once at the trigger boundary. They run * before the Location's Tick Effects and before Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, (int)$row['game_id'], $crossedLocation, $state ); } /* * WAYFARER_LOCATION_EFFECTS_ONLY_RUNTIME_V43 * * Locations no longer own Tick Effects. Game and Region Tick Effects * have already advanced the simulation; a crossed Location contributes * its one-time Effects and then interrupts with Presentation Mode. */ if ($crossedLocation) { $state['navigation']['position'] = $arrivalPosition; $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$crossedLocation['id'], 'phase' => 'presentation', 'trigger' => 'arrival', 'tick' => (int)$state['runtime']['tick'], ]; $state['runtime']['last_location_arrival'] = [ 'location_id' => (int)$crossedLocation['id'], 'region_id' => $regionIdAfterMovement, 'position' => $arrivalPosition, 'tick' => (int)$state['runtime']['tick'], ]; } /* WAYFARER_GAME_ENDINGS_AFTER_TICK_V45 */ $ending = wayfarerRuntimeEvaluateGameEndings( $pdo, (int)$row['game_id'], $state ); $state['runtime']['last_tick_at'] = gmdate('c'); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId ]); $pdo->commit(); $presentation = $state['runtime']['presentation'] ?? null; $pausedForPresentation = is_array($presentation) && !empty($presentation['type']) && !empty($presentation['id']); $ending = $state['runtime']['ending'] ?? null; $ended = is_array($ending) && !empty($ending['id']); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'tick' => (int)$state['runtime']['tick'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'pause' => $pausedForPresentation || $ended, 'pause_reason' => $ended ? 'Game ended.' : ( $pausedForPresentation ? 'Location reached.' : '' ), 'presentation' => $pausedForPresentation ? $presentation : null, 'ending' => $ended ? $ending : null, /* WAYFARER_STORYLET_ACTIONS_TICK_API_V59 */ 'actions' => !$pausedForPresentation && !$ended ? wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$row['game_id'], $state ) : [], /* WAYFARER_TRAVEL_TIME_HUD_V61 */ 'travel' => wayfarerRuntimeBuildTravelStatus( $pdo, (int)$row['game_id'], $state ), ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: _wayfarer_backups/v87-20260929-192300/play/includes/storylet-bindings.php ================================================================================ $key, 'label' => $label !== '' ? $label : $key, 'selector' => $selector, ]; } return $bindings; } function wayfarerRuntimeStoryletBindingRequiredTargets( array $storylet, string $bindingKey ): array { $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); $effects = isset($config['effects']) && is_array($config['effects']) ? $config['effects'] : []; $scope = 'binding:' . $bindingKey; $required = []; foreach ($effects as $effect) { if (!is_array($effect)) { continue; } if ( strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ) !== $scope ) { continue; } $targetType = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); if ( $targetType === '' || $targetId === '' ) { continue; } $required[ $targetType . ':' . $targetId ] = [ 'target_type' => $targetType, 'target_id' => $targetId, ]; } return array_values($required); } function wayfarerRuntimeStoryletBindingFindNamedTrait( array $config, string $normalizedName ): ?array { $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; $match = null; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) || ( array_key_exists( 'active', $trait ) && !$trait['active'] ) ) { continue; } $name = wayfarerRuntimeStoryletBindingNormalizeTraitName( (string)( $trait['name'] ?? '' ) ); if ($name !== $normalizedName) { continue; } /* * Ambiguous duplicate names on one Entity are not safe for a generic * binding target. */ if ($match !== null) { return null; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { return null; } $match = $trait; } return $match; } function wayfarerRuntimeStoryletBindingEntitySupportsTargets( array $entity, array $requiredTargets ): array { $config = wayfarerEntityDecodeConfig( $entity['config'] ?? null ); $targetMap = []; foreach ($requiredTargets as $required) { if (!is_array($required)) { continue; } $targetType = strtolower( trim( (string)( $required['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $required['target_id'] ?? '' ) ); if ( $targetType !== 'trait' || $targetId === '' ) { return [ 'supported' => false, 'target_map' => [], ]; } if ( $targetId === 'sys_item_quantity' || $targetId === 'sys_character_party_member' ) { $targetMap[$targetId] = $targetId; continue; } $namedTrait = wayfarerRuntimeStoryletBindingDecodeNamedTraitId( $targetId ); if ($namedTrait === null) { return [ 'supported' => false, 'target_map' => [], ]; } $trait = wayfarerRuntimeStoryletBindingFindNamedTrait( $config, $namedTrait ); if (!is_array($trait)) { return [ 'supported' => false, 'target_map' => [], ]; } $actualTraitId = trim( (string)( $trait['id'] ?? '' ) ); if ($actualTraitId === '') { return [ 'supported' => false, 'target_map' => [], ]; } $targetMap[$targetId] = $actualTraitId; } return [ 'supported' => true, 'target_map' => $targetMap, ]; } /* WAYFARER_PARTY_BINDINGS_V85 */ function wayfarerRuntimeStoryletBindingFindPlayerTrait( array $gameConfig, string $normalizedName ): ?array { $traits = isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : []; $match = null; foreach ($traits as $trait) { if ( !is_array($trait) || ( array_key_exists('active', $trait) && !$trait['active'] ) ) { continue; } $name = wayfarerRuntimeStoryletBindingNormalizeTraitName( (string)( $trait['name'] ?? '' ) ); if ($name !== $normalizedName) { continue; } if ($match !== null) { return null; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { return null; } $match = $trait; } return $match; } function wayfarerRuntimeStoryletBindingPlayerSupportsTargets( array $gameConfig, array $playerState, array $requiredTargets ): array { $targetMap = []; $runtimeTraits = isset($playerState['traits']) && is_array($playerState['traits']) ? $playerState['traits'] : []; foreach ($requiredTargets as $required) { if (!is_array($required)) { continue; } $targetType = strtolower( trim( (string)( $required['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $required['target_id'] ?? '' ) ); if ( $targetType !== 'trait' || $targetId === '' || $targetId === 'sys_item_quantity' || $targetId === 'sys_character_party_member' ) { return [ 'supported' => false, 'target_map' => [], ]; } $namedTrait = wayfarerRuntimeStoryletBindingDecodeNamedTraitId( $targetId ); if ($namedTrait === null) { return [ 'supported' => false, 'target_map' => [], ]; } $trait = wayfarerRuntimeStoryletBindingFindPlayerTrait( $gameConfig, $namedTrait ); if (!is_array($trait)) { return [ 'supported' => false, 'target_map' => [], ]; } $actualTraitId = trim( (string)( $trait['id'] ?? '' ) ); if ( $actualTraitId === '' || !array_key_exists( $actualTraitId, $runtimeTraits ) ) { return [ 'supported' => false, 'target_map' => [], ]; } $targetMap[$targetId] = $actualTraitId; } return [ 'supported' => true, 'target_map' => $targetMap, ]; } function wayfarerRuntimeStoryletBindingCandidates( PDO $pdo, int $gameId, array $storylet, array $binding, array $runtimeState ): array { $selector = strtolower( trim( (string)( $binding['selector'] ?? '' ) ) ); $bindingKey = wayfarerRuntimeStoryletBindingNormalizeKey( (string)( $binding['key'] ?? '' ) ); $requiredTargets = wayfarerRuntimeStoryletBindingRequiredTargets( $storylet, $bindingKey ); $candidates = []; /* * Named setup Player Characters are party members too. They do not use the * Entity Party Membership Trait; their presence in runtime.players is the * canonical membership signal for the playthrough. */ if ($selector === 'random_party_character') { $gameConfig = function_exists('wayfarerRuntimeLoadGameConfig') ? wayfarerRuntimeLoadGameConfig( $pdo, $gameId ) : []; $players = isset($runtimeState['players']) && is_array($runtimeState['players']) ? $runtimeState['players'] : []; foreach ($players as $playerKey => $playerState) { if (!is_array($playerState)) { continue; } $name = trim( (string)( $playerState['name'] ?? '' ) ); if ($name === '') { continue; } $support = wayfarerRuntimeStoryletBindingPlayerSupportsTargets( $gameConfig, $playerState, $requiredTargets ); if (empty($support['supported'])) { continue; } $slot = (int)( $playerState['slot'] ?? $playerKey ); $candidates[] = [ 'runtime_scope' => 'player:' . $slot, 'subject_kind' => 'player', 'player_slot' => $slot, 'entity_id' => 0, 'entity_type' => 'player', 'name' => $name, 'target_map' => $support['target_map'], ]; } } foreach ( wayfarerRuntimeLoadActiveEntities( $pdo, $gameId ) as $entity ) { if (!is_array($entity)) { continue; } $entityId = (int)( $entity['id'] ?? 0 ); if ($entityId <= 0) { continue; } $entityType = strtolower( trim( (string)( $entity['entity_type'] ?? '' ) ) ); $entityState = $runtimeState['entities'][ (string)$entityId ] ?? null; if (!is_array($entityState)) { continue; } $traits = isset($entityState['traits']) && is_array($entityState['traits']) ? $entityState['traits'] : []; if ( $selector === 'random_party_character' ) { if ( $entityType !== 'character' || empty( $traits[ 'sys_character_party_member' ] ) ) { continue; } } elseif ( $selector === 'random_possessed_item' ) { $quantity = $traits[ 'sys_item_quantity' ] ?? 0; if ( $entityType !== 'item' || !is_numeric($quantity) || (float)$quantity <= 0 ) { continue; } } else { continue; } $support = wayfarerRuntimeStoryletBindingEntitySupportsTargets( $entity, $requiredTargets ); if (empty($support['supported'])) { continue; } $candidates[] = [ 'runtime_scope' => 'entity:' . $entityId, 'subject_kind' => 'entity', 'player_slot' => null, 'entity_id' => $entityId, 'entity_type' => $entityType, 'name' => trim( (string)( $entity['name'] ?? ('Entity ' . $entityId) ) ), 'target_map' => $support['target_map'], ]; } return $candidates; } function wayfarerRuntimeStoryletBindingsAvailable( PDO $pdo, int $gameId, array $storylet, array $runtimeState ): bool { $definitions = wayfarerRuntimeStoryletBindingDefinitions( $storylet ); foreach ($definitions as $binding) { if ( wayfarerRuntimeStoryletBindingCandidates( $pdo, $gameId, $storylet, $binding, $runtimeState ) === [] ) { return false; } } return true; } function wayfarerRuntimeResolveStoryletBindings( PDO $pdo, int $gameId, array $storylet, array $runtimeState ): array { $definitions = wayfarerRuntimeStoryletBindingDefinitions( $storylet ); $resolved = []; foreach ( $definitions as $key => $binding ) { $candidates = wayfarerRuntimeStoryletBindingCandidates( $pdo, $gameId, $storylet, $binding, $runtimeState ); if ($candidates === []) { return [ 'bindings' => [], 'error' => 'Storylet Binding "' . ( $binding['label'] ?? $key ) . '" has no eligible runtime target.', ]; } $selected = $candidates[ random_int( 0, count($candidates) - 1 ) ]; $resolved[$key] = [ 'key' => $key, 'label' => (string)( $binding['label'] ?? $key ), 'selector' => (string)( $binding['selector'] ?? '' ), 'runtime_scope' => (string)( $selected['runtime_scope'] ?? '' ), 'subject_kind' => (string)( $selected['subject_kind'] ?? 'entity' ), 'player_slot' => isset($selected['player_slot']) ? (int)$selected['player_slot'] : null, 'entity_id' => (int)( $selected['entity_id'] ?? 0 ), 'entity_type' => (string)( $selected['entity_type'] ?? '' ), 'name' => (string)$selected['name'], 'target_map' => is_array( $selected['target_map'] ?? null ) ? $selected['target_map'] : [], ]; } return [ 'bindings' => $resolved, 'error' => '', ]; } function wayfarerRuntimeMaterializeStoryletBindingEffect( array $effect, array $bindings ): array { $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); if ( preg_match( '/^binding:([a-z][a-z0-9_]{0,31})$/', $scope, $match ) !== 1 ) { return [ 'effect' => $effect, 'error' => '', ]; } $key = $match[1]; $binding = $bindings[$key] ?? null; if (!is_array($binding)) { return [ 'effect' => [], 'error' => 'Resolved Storylet Binding "' . $key . '" is missing.', ]; } $runtimeScope = strtolower( trim( (string)( $binding['runtime_scope'] ?? '' ) ) ); /* * Compatibility for V79-V84 saved runtime binding records, which stored * only entity_id. */ if ($runtimeScope === '') { $entityId = (int)( $binding['entity_id'] ?? 0 ); if ($entityId > 0) { $runtimeScope = 'entity:' . $entityId; } } if ( preg_match( '/^(?:entity:[1-9][0-9]*|player:[0-9]+)$/', $runtimeScope ) !== 1 ) { return [ 'effect' => [], 'error' => 'Resolved Storylet Binding "' . $key . '" does not point to a valid Party Member or Entity.', ]; } $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); $targetMap = is_array( $binding['target_map'] ?? null ) ? $binding['target_map'] : []; $entityScope = strpos( $runtimeScope, 'entity:' ) === 0; $actualTargetId = $targetMap[$targetId] ?? ( $entityScope && ( $targetId === 'sys_item_quantity' || $targetId === 'sys_character_party_member' ) ? $targetId : '' ); if ($actualTargetId === '') { return [ 'effect' => [], 'error' => 'The selected subject for Binding "' . $key . '" does not expose the required Trait.', ]; } $materialized = $effect; $materialized['target_scope'] = $runtimeScope; $materialized['target_id'] = $actualTargetId; if ( $entityScope && $actualTargetId === 'sys_item_quantity' ) { $materialized['target_system_key'] = 'quantity'; } elseif ( $entityScope && $actualTargetId === 'sys_character_party_member' ) { $materialized['target_system_key'] = 'party_member'; } else { unset( $materialized['target_system_key'] ); } return [ 'effect' => $materialized, 'error' => '', ]; } function wayfarerRuntimeInterpolateStoryletBindings( string $text, array $bindings ): string { if ( $text === '' || $bindings === [] ) { return $text; } $replace = function ( array $match ) use ( $bindings ): string { $key = wayfarerRuntimeStoryletBindingNormalizeKey( (string)( $match[1] ?? '' ) ); $binding = $bindings[$key] ?? null; if (!is_array($binding)) { return $match[0]; } return (string)( $binding['name'] ?? $match[0] ); }; /* * {{victim.name}} and the shorter {{victim}} both resolve to the bound * subject's current name. */ $text = preg_replace_callback( '/\{\{\s*([a-z][a-z0-9_]*)\.name\s*\}\}/i', $replace, $text ) ?? $text; $text = preg_replace_callback( '/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/i', $replace, $text ) ?? $text; return $text; } ================================================================================ FILE: _wayfarer_backups/v88-20260929-205950/admin/builder.php ================================================================================ prepare(" SELECT id, name, config FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; /* WAYFARER_STORYLET_BUILDER_V56 * A + New Branch Storylet link can preselect its parent and choose a sensible * sibling order without making parent_storylet_id part of the generic request. */ if ( $type === 'storylets' && !$id && !empty($_GET['parent_storylet_id']) ) { $requestedParentId = (int)$_GET['parent_storylet_id']; $parentStmt = $pdo->prepare( " SELECT id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $parentStmt->execute([ $requestedParentId, $gameId, ]); if ($parentStmt->fetchColumn()) { $record['parent_storylet_id'] = $requestedParentId; $orderStmt = $pdo->prepare( " SELECT COALESCE(MAX(branch_sort_order), 0) FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? " ); $orderStmt->execute([ $gameId, $requestedParentId, ]); $record['branch_sort_order'] = (int)$orderStmt->fetchColumn() + 10; } } // ================================================================ // SAVE / DELETE / DUPLICATE // WAYFARER_STORYLET_DUPLICATE_V83 // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'duplicate') { $duplicateId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; if ( ($definition['table'] ?? '') !== 'wf_storylets' ) { $error = 'Only Storylets can be duplicated.'; } else { $duplicateResult = duplicateStoryletRecord( $pdo, $gameId, $duplicateId ); $error = $duplicateResult['error']; if ($duplicateResult['duplicated']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) . '&id=' . (int)$duplicateResult['id'] . '&tab=basics' . '&duplicated=1' ); exit; } } } elseif ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { /* * Media Library associations are applied only after the authored * object has saved successfully and therefore has a stable ID. */ $savedRecordId = (int)$saveResult['id']; $mediaResult = applyBuilderMediaSelections( $pdo, $gameId, $definition['table'], $savedRecordId, $_POST, $_FILES ); if (!$mediaResult['saved']) { /* * The authored object itself has already saved. Keep the * editor on that record and report the media failure * accurately rather than pretending the object save failed. */ $id = $savedRecordId; $record['id'] = $savedRecordId; $error = 'Record saved, but media was not changed: ' . $mediaResult['error']; } else { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) . '&id=' . (int)$saveResult['id'] . '&tab=' . rawurlencode( (string)($_POST['active_tab'] ?? 'basics') ) ); exit; } } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder

================================================================================ FILE: _wayfarer_backups/v88-20260929-205950/assets/css/builder.css ================================================================================ /* |-------------------------------------------------------------------------- | Wayfarer Builder |-------------------------------------------------------------------------- | Master/detail authored-object workspace. | This stylesheet is intentionally presentation-only. */ :root { --wf-border: #d8dbe2; --wf-border-strong: #b8bdc7; --wf-surface: #ffffff; --wf-surface-soft: #f6f7f9; --wf-text: #20242a; --wf-muted: #667085; --wf-accent: #2f5ea8; --wf-accent-soft: #edf3fc; --wf-danger: #a61b2b; --wf-danger-soft: #fff4f5; --wf-radius: 8px; --wf-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); } .wayfarer-builder-page { margin: 0; padding: 24px; background: #f3f4f6; color: var(--wf-text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.45; } .wayfarer-builder-page > h1, .wayfarer-builder-page > p, .wayfarer-builder-page > nav, .wayfarer-builder-page > .builder-workspace, .wayfarer-builder-page > .builder-back-link { max-width: 1400px; margin-left: auto; margin-right: auto; } .wayfarer-builder-page > h1 { margin-bottom: 2px; } .wayfarer-builder-page > h1 + p { margin-top: 0; color: var(--wf-muted); } .wayfarer-builder-page > nav { margin-top: 18px; margin-bottom: 18px; padding: 10px 14px; background: var(--wf-surface); border: 1px solid var(--wf-border); border-radius: var(--wf-radius); box-shadow: var(--wf-shadow); } .wayfarer-builder-page > nav a, .wayfarer-builder-page > nav strong { display: inline-block; margin-right: 18px; } .wayfarer-builder-page > hr { display: none; } /* ------------------------------------------------------------------ MASTER / DETAIL ------------------------------------------------------------------ */ .builder-workspace { display: grid; grid-template-columns: minmax(260px, 340px) minmax(0, 1fr); gap: 18px; align-items: start; } .builder-collection-pane, .builder-editor-pane { background: var(--wf-surface); border: 1px solid var(--wf-border); border-radius: var(--wf-radius); box-shadow: var(--wf-shadow); } .builder-collection-pane { position: sticky; top: 18px; max-height: calc(100vh - 36px); overflow: hidden; } .builder-collection-toolbar { padding: 14px; border-bottom: 1px solid var(--wf-border); background: var(--wf-surface-soft); } .builder-new-object { display: inline-block; padding: 8px 11px; border: 1px solid var(--wf-border-strong); border-radius: 6px; background: var(--wf-surface); text-decoration: none; font-weight: 600; } .builder-collection-scroll { overflow: auto; max-height: calc(100vh - 92px); padding: 14px; } .builder-collection-scroll > h2 { margin-top: 0; margin-bottom: 12px; font-size: 1.15rem; } .builder-collection-scroll ul { margin: 0; padding: 0; list-style: none; } .builder-collection-scroll li + li { margin-top: 4px; } .builder-collection-scroll li a { display: block; padding: 8px 9px; border-radius: 5px; text-decoration: none; } .builder-collection-scroll li a:hover { background: var(--wf-accent-soft); } .builder-collection-scroll table { width: 100%; border-collapse: collapse; font-size: 0.88rem; } .builder-collection-scroll th, .builder-collection-scroll td { padding: 7px 6px; border: 1px solid var(--wf-border); text-align: left; vertical-align: top; } .builder-collection-scroll th button { border: 0; padding: 0; background: transparent; font: inherit; font-weight: 600; cursor: pointer; } .builder-editor-pane { min-width: 0; padding: 20px 24px 24px; } .builder-editor-pane > h2 { margin-top: 0; margin-bottom: 14px; } /* WAYFARER_OBJECT_CONTEXT_HEADING_V34 */ .builder-object-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 8px; } .builder-object-context { color: var(--wf-muted); font-weight: 600; } .builder-object-context[hidden] { display: none; } .builder-object-context-separator { margin-right: 8px; color: var(--wf-border-strong); } /* ------------------------------------------------------------------ TABBED OBJECT EDITOR ------------------------------------------------------------------ */ .builder-object-form { margin: 0; } .builder-editor-tabs { display: flex; gap: 4px; margin: 0 0 20px; padding: 0; border-bottom: 1px solid var(--wf-border); } .builder-editor-tab { position: relative; bottom: -1px; padding: 9px 14px; border: 1px solid transparent; border-bottom: 1px solid var(--wf-border); background: transparent; color: var(--wf-muted); font: inherit; font-weight: 600; cursor: pointer; } .builder-editor-tab:hover { color: var(--wf-text); background: var(--wf-surface-soft); } .builder-editor-tab.is-active { color: var(--wf-accent); background: var(--wf-surface); border-color: var(--wf-border) var(--wf-border) var(--wf-surface); border-radius: 6px 6px 0 0; } .builder-tab-panel[hidden] { display: none !important; } .builder-tab-panel > p:first-child { margin-top: 0; } /* ------------------------------------------------------------------ EXISTING FIELD CONTROLS ------------------------------------------------------------------ */ .builder-editor-pane label { font-weight: 600; } .builder-editor-pane small { color: var(--wf-muted); font-weight: 400; } .builder-editor-pane input[type="text"], .builder-editor-pane input[type="number"], .builder-editor-pane input[type="file"], .builder-editor-pane select, .builder-editor-pane textarea { box-sizing: border-box; max-width: 100%; margin-top: 4px; padding: 7px 8px; border: 1px solid var(--wf-border-strong); border-radius: 5px; background: var(--wf-surface); color: var(--wf-text); font: inherit; } .builder-editor-pane input[type="text"], .builder-editor-pane textarea { width: min(100%, 720px); } .builder-editor-pane textarea { resize: vertical; } .builder-editor-pane fieldset { margin: 0 0 16px; padding: 14px 16px; border: 1px solid var(--wf-border); border-radius: 7px; } .builder-editor-pane legend { padding: 0 6px; } .builder-editor-pane table { max-width: 100%; border-collapse: collapse; } .builder-editor-pane th, .builder-editor-pane td { padding: 7px; } /* System settings stay visually inside Basics. */ .builder-editor-pane [data-system-trait-settings] { margin: 16px 0; padding: 14px 16px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface-soft); } .builder-editor-pane [data-system-trait-settings] h3 { margin-top: 0; } /* ------------------------------------------------------------------ SAVE / DELETE ------------------------------------------------------------------ */ .builder-save-bar { display: flex; justify-content: flex-end; margin: 22px -24px -24px; padding: 14px 24px; border-top: 1px solid var(--wf-border); background: var(--wf-surface-soft); border-radius: 0 0 var(--wf-radius) var(--wf-radius); } .builder-save-bar button { padding: 9px 16px; border: 1px solid #214b86; border-radius: 6px; background: var(--wf-accent); color: #fff; font: inherit; font-weight: 600; cursor: pointer; } .builder-editor-pane [data-builder-delete-form] { margin-top: 28px; padding: 16px; border: 1px solid #e8b8be; border-radius: 7px; background: var(--wf-danger-soft); } .builder-editor-pane [data-builder-delete-form] h3 { margin-top: 0; color: var(--wf-danger); } .builder-editor-pane [data-builder-delete-form] button { padding: 8px 12px; border: 1px solid var(--wf-danger); border-radius: 5px; background: #fff; color: var(--wf-danger) !important; font: inherit; font-weight: 600; cursor: pointer; } .builder-back-link { margin-top: 18px; } /* ------------------------------------------------------------------ RESPONSIVE ------------------------------------------------------------------ */ @media (max-width: 900px) { .wayfarer-builder-page { padding: 14px; } .builder-workspace { grid-template-columns: 1fr; } .builder-collection-pane { position: static; max-height: none; } .builder-collection-scroll { max-height: 300px; } .builder-editor-pane { padding: 18px; } .builder-save-bar { margin-left: -18px; margin-right: -18px; margin-bottom: -18px; padding-left: 18px; padding-right: 18px; } } @media (max-width: 560px) { .builder-editor-tabs { overflow-x: auto; } .builder-editor-tab { white-space: nowrap; } } /* |-------------------------------------------------------------------------- | REGION TICK EFFECTS v18 |-------------------------------------------------------------------------- */ .builder-tick-effect-row { display: grid; grid-template-columns: minmax(220px, 2fr) minmax(130px, 1fr) minmax(110px, 1fr) auto; gap: 12px; align-items: end; margin: 0 0 12px; padding: 14px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface-soft); } .builder-tick-effect-row > p { margin: 0; } .builder-tick-effect-row select, .builder-tick-effect-row input { width: 100%; box-sizing: border-box; } .builder-tick-effect-remove { align-self: end; } @media (max-width: 760px) { .builder-tick-effect-row { grid-template-columns: 1fr; } } /* |-------------------------------------------------------------------------- | TICK EFFECT RESULT BRANCHES v28 |-------------------------------------------------------------------------- | WAYFARER_TICK_EFFECT_BRANCHES_V28 */ .builder-tick-effect-row { display: block; margin: 0 0 16px; padding: 16px; } .builder-tick-effect-header { display: grid; grid-template-columns: minmax(260px, 1fr) auto; gap: 16px; align-items: end; margin-bottom: 16px; } .builder-tick-effect-target select { display: block; width: 100%; margin-top: 6px; } .builder-tick-result-list { display: grid; gap: 12px; } .builder-tick-result-branch { padding: 14px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface); } .builder-tick-result-branch.is-otherwise { background: var(--wf-surface-soft); } .builder-tick-result-heading { display: flex; justify-content: space-between; gap: 12px; align-items: center; margin-bottom: 12px; } .builder-tick-result-order { display: flex; gap: 6px; align-items: center; } .builder-tick-result-condition { margin-bottom: 10px; } .builder-tick-result-then { margin: 10px 0 8px; } .builder-tick-result-fields { display: grid; grid-template-columns: minmax(150px, 0.8fr) minmax(120px, 0.7fr) minmax(120px, 0.7fr) minmax(180px, 1fr); gap: 12px; align-items: end; } .builder-tick-result-fields select, .builder-tick-result-fields input { display: block; width: 100%; margin-top: 6px; } .builder-tick-result-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; } .builder-condition-group { padding: 10px; border: 1px solid var(--wf-border); border-radius: 6px; background: var(--wf-surface-soft); } .builder-condition-group-header, .builder-condition-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .builder-condition-children { display: grid; gap: 8px; margin: 10px 0; } .builder-condition-row { display: grid; grid-template-columns: minmax(180px, 1.4fr) minmax(180px, 1fr) minmax(120px, 1fr) auto; gap: 8px; align-items: end; } .builder-condition-row select, .builder-condition-row input, .builder-condition-group-header select { width: 100%; box-sizing: border-box; } @media (max-width: 760px) { .builder-tick-effect-header, .builder-tick-result-fields, .builder-condition-row { grid-template-columns: 1fr; } .builder-tick-result-heading { align-items: flex-start; flex-direction: column; } } /* ------------------------------------------------------------------ STARTING CHOICES WAYFARER_STARTING_CHOICES_V36 ------------------------------------------------------------------ */ .builder-starting-choices-editor { margin-top: 24px; } .builder-starting-choices-heading, .builder-starting-choice-header, .builder-starting-option-header, .builder-starting-effects-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } .builder-starting-choices-heading h3 { margin: 0 0 4px; } .builder-starting-choices-heading p { margin: 0; } .builder-starting-choice { margin-top: 16px; padding: 16px; border: 1px solid var(--wf-border); border-radius: var(--wf-radius); background: var(--wf-surface-soft); } .builder-starting-choice-fields { display: grid; grid-template-columns: minmax(0, 1fr) 140px; gap: 12px; margin-top: 14px; } .builder-starting-choice-fields label, .builder-starting-option-fields label, .builder-starting-effect label { display: grid; gap: 5px; font-weight: 600; } .builder-starting-choice-fields input, .builder-starting-choice-fields select, .builder-starting-option-fields input, .builder-starting-option-fields textarea, .builder-starting-effect input, .builder-starting-effect select { width: 100%; font: inherit; } .builder-starting-options { display: grid; gap: 12px; margin: 16px 0 12px; } .builder-starting-option { padding: 14px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface); } .builder-starting-option-fields { display: grid; grid-template-columns: minmax(180px, 0.7fr) minmax(260px, 1.3fr); gap: 12px; margin-top: 12px; } .builder-starting-effects { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--wf-border); } .builder-starting-effects-heading { margin-bottom: 8px; } .builder-starting-effects-heading small { color: var(--wf-muted); } .builder-starting-effect { display: grid; grid-template-columns: minmax(180px, 1.4fr) minmax(110px, 0.65fr) minmax(110px, 0.65fr) minmax(110px, 0.65fr) minmax(180px, 1fr) auto; gap: 10px; align-items: end; margin-bottom: 8px; } @media (max-width: 800px) { .builder-starting-choice-fields, .builder-starting-option-fields, .builder-starting-effect { grid-template-columns: 1fr; } } /* ------------------------------------------------------------------ ONE-TIME EFFECTS WAYFARER_ONE_TIME_EFFECTS_EDITOR_V42 ------------------------------------------------------------------ */ .builder-effects-panel > hr { margin: 28px 0; border: 0; border-top: 1px solid var(--wf-border, #d8dbe2); } .builder-effects-list { display: grid; gap: 10px; margin: 14px 0; } .builder-effect-row { display: grid; grid-template-columns: minmax(220px, 1.5fr) minmax(130px, 0.7fr) minmax(110px, 0.6fr) auto; gap: 10px; align-items: end; padding: 12px; border: 1px solid var(--wf-border, #d8dbe2); border-radius: 8px; background: #ffffff; } .builder-effect-row label { display: grid; gap: 5px; } .builder-effect-row select, .builder-effect-row input { width: 100%; } .builder-effect-remove { align-self: end; } @media (max-width: 850px) { .builder-effect-row { grid-template-columns: 1fr; } .builder-effect-remove { justify-self: start; } } /* ------------------------------------------------------------------ GENERALIZED EFFECTS WAYFARER_GENERALIZED_EFFECT_EDITOR_V43 ------------------------------------------------------------------ */ /* * v42 displayed an Effect as one horizontal operation row. v43 Effects own * ordered result branches, so use the same card language as Tick Effects. */ .builder-effect-row { display: block; grid-template-columns: none; padding: 14px; } .builder-effect-row .builder-tick-effect-header { margin-bottom: 12px; } .builder-effect-value-fields { display: grid; grid-template-columns: minmax(120px, 0.75fr) minmax(120px, 0.75fr) minmax(120px, 0.75fr); gap: 10px; align-items: end; } .builder-effect-random-range { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .builder-effect-random-range > label { display: grid; gap: 5px; } [data-effect-fixed-wrap][hidden], [data-effect-random-wrap][hidden], [data-effect-value-mode-wrap][hidden] { display: none !important; } /* WAYFARER_DATATYPE_TARGET_UI_V58 */ [data-condition-value-input][hidden], [data-condition-value-select][hidden], [data-starting-value-mode-wrap][hidden], [data-starting-fixed-wrap][hidden], [data-starting-random-wrap][hidden], [data-starting-fixed-input][hidden], [data-starting-fixed-choice][hidden], [data-tick-value-mode-wrap][hidden], [data-tick-branch-value-wrap][hidden], [data-tick-random-wrap][hidden], [data-tick-fixed-choice][hidden], [data-effect-fixed-choice][hidden] { display: none !important; } @media (max-width: 850px) { .builder-effect-value-fields { grid-template-columns: 1fr; } .builder-effect-random-range { grid-template-columns: 1fr; } } /* ------------------------------------------------------------------ STORYLET BRANCHES WAYFARER_STORYLET_BUILDER_V56 ------------------------------------------------------------------ */ .builder-storylet-branches { max-width: 100%; overflow-x: auto; } .builder-storylet-branches table { width: 100%; border-collapse: collapse; } .builder-storylet-branches th, .builder-storylet-branches td { padding: 9px 10px; border: 1px solid var(--wf-border); text-align: left; vertical-align: top; } .builder-storylet-branches th { background: var(--wf-surface-soft); } ================================================================================ FILE: _wayfarer_backups/v88-20260929-205950/assets/js/builder.js ================================================================================ const wayfarerConditionData = window.wayfarerConditionData || {}; function updateConditionalConfigFields() { document .querySelectorAll('[data-config-field]') .forEach(function (container) { const relationshipName = container.dataset.conditionRelationship; const requiredValue = container.dataset.conditionEquals; if (!relationshipName) { container.style.display = ''; return; } const relationshipSelect = document.querySelector( '[data-relationship="' + relationshipName + '"]' ); if (!relationshipSelect) { container.style.display = 'none'; return; } const selectedId = relationshipSelect.value; const actualValue = wayfarerConditionData[relationshipName]?.[selectedId]; container.style.display = actualValue === requiredValue ? '' : 'none'; }); } document .querySelectorAll('[data-relationship]') .forEach(function (select) { select.addEventListener( 'change', updateConditionalConfigFields ); }); // ================================================================ // SYSTEM TRAIT GROUPS // ================================================================ function updateSystemTraitGroups(container) { const selectorField = container.dataset.selectorField; if (!selectorField) { return; } const selector = document.querySelector( '[name="' + selectorField + '"]' ); if (!selector) { return; } const selectedGroup = selector.value; container .querySelectorAll('[data-system-trait-group]') .forEach(function (group) { const active = group.dataset.systemTraitGroup === selectedGroup; group.style.display = active ? '' : 'none'; group .querySelectorAll('input, select, textarea') .forEach(function (input) { input.disabled = !active; }); }); } document .querySelectorAll('[data-system-trait-settings]') .forEach(function (container) { const selectorField = container.dataset.selectorField; const selector = selectorField ? document.querySelector( '[name="' + selectorField + '"]' ) : null; if (selector) { selector.addEventListener( 'change', function () { updateSystemTraitGroups(container); } ); } updateSystemTraitGroups(container); }); // ================================================================ // COLLECTION SORTING // ================================================================ document .querySelectorAll('[data-sort-column]') .forEach(function (button) { button.addEventListener('click', function () { const table = button.closest('[data-collection-table]'); if (!table) { return; } const tbody = table.querySelector('tbody'); const columnName = button.dataset.sortColumn; const sortType = button.dataset.sortType || 'text'; const currentDirection = button.dataset.sortDirection || 'none'; const nextDirection = currentDirection === 'asc' ? 'desc' : 'asc'; table .querySelectorAll('[data-sort-column]') .forEach(function (otherButton) { otherButton.dataset.sortDirection = 'none'; }); button.dataset.sortDirection = nextDirection; const rows = Array.from(tbody.querySelectorAll('tr')); rows.sort(function (a, b) { const aCell = a.querySelector( '[data-column="' + columnName + '"]' ); const bCell = b.querySelector( '[data-column="' + columnName + '"]' ); const aValue = aCell?.dataset.sortValue ?? ''; const bValue = bCell?.dataset.sortValue ?? ''; let comparison = 0; if (sortType === 'number') { const aNumber = aValue === '' ? Number.POSITIVE_INFINITY : Number(aValue); const bNumber = bValue === '' ? Number.POSITIVE_INFINITY : Number(bValue); comparison = aNumber - bNumber; } else { comparison = aValue.localeCompare( bValue, undefined, { numeric: true, sensitivity: 'base' } ); } return nextDirection === 'asc' ? comparison : -comparison; }); rows.forEach(function (row) { tbody.appendChild(row); }); }); }); updateConditionalConfigFields(); // ================================================================ // MEDIA LIBRARY CONTROLS // ================================================================ function updateBuilderMediaSlot(slotContainer) { const checkedAction = slotContainer.querySelector( 'input[type="radio"][name^="media_action"]:checked' ); const action = checkedAction ? checkedAction.value : 'none'; const existingSelect = slotContainer.querySelector( '[data-media-existing]' ); if (existingSelect) { existingSelect.disabled = action !== 'existing'; } slotContainer .querySelectorAll( '[data-media-upload-fields] input' ) .forEach(function (input) { input.disabled = action !== 'upload'; }); } document .querySelectorAll('[data-media-slot]') .forEach(function (slotContainer) { slotContainer .querySelectorAll( 'input[type="radio"][name^="media_action"]' ) .forEach(function (radio) { radio.addEventListener( 'change', function () { updateBuilderMediaSlot( slotContainer ); } ); }); updateBuilderMediaSlot( slotContainer ); }); // ================================================================ // AUTHORED OBJECT CONTEXT HEADING // WAYFARER_OBJECT_CONTEXT_HEADING_V34 // ================================================================ function initializeBuilderObjectContextHeading() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } const heading = editor.querySelector( '[data-builder-object-heading]' ); if (!heading) { return; } const fieldName = heading.dataset.builderObjectLabelField || 'name'; const form = editor.querySelector( 'form' ); if (!form) { return; } const field = Array .from( form.elements ) .find(function (element) { return ( element && element.name === fieldName ); }); if (!field) { return; } const context = heading.querySelector( '[data-builder-object-context]' ); const label = heading.querySelector( '[data-builder-object-context-label]' ); if (!context || !label) { return; } function refreshContextHeading() { const value = String( field.value || '' ).trim(); label.textContent = value; context.hidden = value === ''; } field.addEventListener( 'input', refreshContextHeading ); field.addEventListener( 'change', refreshContextHeading ); refreshContextHeading(); } initializeBuilderObjectContextHeading(); // ================================================================ // AUTHORED OBJECT EDITOR TABS // ================================================================ function initializeBuilderEditorTabs() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } /* * The editor component has one primary save form. The separate delete * form, when present, lives later in the editor pane and is deliberately * left outside the tab system. */ const form = Array .from(editor.children) .find(function (child) { return child.tagName === 'FORM'; }); if ( !form || form.dataset.builderTabbed === '1' ) { return; } form.dataset.builderTabbed = '1'; form.classList.add( 'builder-object-form' ); /* * WAYFARER_ACTIVE_TAB_V20 * * Persist the selected editor tab through POST/redirect/reload. */ const activeTabInput = document.createElement( 'input' ); activeTabInput.type = 'hidden'; activeTabInput.name = 'active_tab'; const requestedTab = new URLSearchParams( window.location.search ).get( 'tab' ) || 'basics'; activeTabInput.value = requestedTab; form.appendChild( activeTabInput ); const originalChildren = Array.from( form.children ); const panels = { basics: document.createElement('div'), media: document.createElement('div'), traits: document.createElement('div'), /* WAYFARER_CONDITIONS_PANEL_V45_1 */ conditions: document.createElement('div'), /* WAYFARER_LINEAR_TRAVEL_TAB_V72 */ travel: document.createElement('div'), tick_effects: document.createElement('div'), /* WAYFARER_STORYLET_BUILDER_V56 */ /* WAYFARER_STORYLET_BRANCH_PANEL_KEY_V57 */ branches: document.createElement('div') }; panels.basics.className = 'builder-tab-panel'; panels.media.className = 'builder-tab-panel'; panels.traits.className = 'builder-tab-panel'; panels.conditions.className = 'builder-tab-panel'; panels.travel.className = 'builder-tab-panel'; panels.branches.className = 'builder-tab-panel'; panels.basics.dataset.builderPanel = 'basics'; panels.media.dataset.builderPanel = 'media'; panels.traits.dataset.builderPanel = 'traits'; panels.conditions.dataset.builderPanel = 'conditions'; panels.travel.dataset.builderPanel = 'travel'; panels.branches.dataset.builderPanel = 'branches'; panels.tick_effects.className = 'builder-tab-panel'; panels.tick_effects.dataset.builderPanel = 'tick_effects'; let section = 'basics'; let saveContainer = null; let hasMedia = false; let hasTraits = false; let hasConditions = false; let hasTravel = false; let hasTickEffects = false; let hasBranches = false; originalChildren.forEach( function (child) { /* * Keep hidden record identifiers directly in the form. */ if ( child.tagName === 'INPUT' && child.type === 'hidden' ) { return; } /* * Save becomes a stable footer beneath the tab panels. */ const submitButton = child.querySelector ? child.querySelector( 'button[type="submit"], input[type="submit"]' ) : null; if (submitButton) { saveContainer = child; return; } /* * Ignore the separator immediately before a tab heading. * Other HR elements are harmless but unnecessary inside the * cleaned-up editor. */ if (child.tagName === 'HR') { child.remove(); return; } if (child.tagName === 'H3') { const heading = child.textContent .trim() .toLowerCase(); if (heading === 'media') { section = 'media'; hasMedia = true; child.remove(); return; } if (heading === 'traits') { section = 'traits'; hasTraits = true; child.remove(); return; } } /* * WAYFARER_CONDITIONS_PANEL_V45_1 * * Conditions answer whether an authored object is eligible; * Effects answer what changes when it triggers. */ if ( child.hasAttribute && child.hasAttribute( 'data-builder-conditions-panel' ) ) { section = 'conditions'; hasConditions = true; } /* WAYFARER_LINEAR_TRAVEL_TAB_V72 */ if ( child.hasAttribute && child.hasAttribute( 'data-builder-travel-panel' ) ) { section = 'travel'; hasTravel = true; } /* * WAYFARER_ONE_TIME_EFFECTS_EDITOR_V42 * * Location Effects and Tick Effects share one Effects panel. */ if ( child.hasAttribute && ( child.hasAttribute( 'data-builder-effects-panel' ) || child.hasAttribute( 'data-builder-tick-effects' ) ) ) { section = 'tick_effects'; hasTickEffects = true; } /* WAYFARER_STORYLET_BRANCHES_PANEL_V56 */ if ( child.hasAttribute && child.hasAttribute( 'data-builder-branches-panel' ) ) { section = 'branches'; hasBranches = true; } /* * WAYFARER_LINEAR_TRAVEL_TAB_V72 * * Distance Label is existing Region travel configuration, so move * it into the Travel tab when that tab exists. Other config fields * retain the original Basics behavior. */ if ( child.hasAttribute && child.hasAttribute( 'data-config-field' ) ) { const configFieldName = child.dataset.configField || ''; const hasTravelEditor = !!form.querySelector( '[data-builder-travel-panel]' ); if ( configFieldName === 'distance_label' && hasTravelEditor ) { section = 'travel'; hasTravel = true; } else { section = 'basics'; } } /* Keep the existing Region HUD in Basics. */ if ( child.hasAttribute && child.hasAttribute( 'data-region-hud-editor' ) ) { section = 'basics'; } /* * A Media fieldset is definitive even if future markup changes * around the Media heading. */ if ( child.hasAttribute && child.hasAttribute( 'data-media-slot' ) ) { section = 'media'; hasMedia = true; } panels[section] .appendChild( child ); } ); /* * If Traits exist, anything moved after the Traits heading belongs there. * System traits do not use that heading and therefore stay in Basics. */ if ( panels.traits.children.length > 0 ) { hasTraits = true; } if ( panels.media.children.length > 0 ) { hasMedia = true; } if ( panels.conditions.children.length > 0 ) { hasConditions = true; } if ( panels.travel.children.length > 0 ) { hasTravel = true; } if ( panels.tick_effects.children.length > 0 ) { hasTickEffects = true; } if ( panels.branches.children.length > 0 ) { hasBranches = true; } const tabBar = document.createElement( 'div' ); tabBar.className = 'builder-editor-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const availablePanels = [ { key: 'basics', label: 'Basics', show: true }, { key: 'media', label: 'Media', show: hasMedia }, { key: 'traits', label: 'Traits', show: hasTraits }, { key: 'travel', label: 'Travel', show: hasTravel }, { key: 'conditions', label: 'Conditions', show: hasConditions }, { key: 'tick_effects', label: 'Effects', show: hasTickEffects }, { key: 'branches', label: 'Branches', show: hasBranches } ].filter( function (item) { return item.show; } ); function activatePanel(panelKey) { activeTabInput.value = panelKey; tabBar .querySelectorAll( '[data-builder-tab]' ) .forEach( function (button) { const active = button.dataset.builderTab === panelKey; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); Object .entries(panels) .forEach( function (entry) { const key = entry[0]; const panel = entry[1]; panel.hidden = key !== panelKey; } ); } availablePanels.forEach( function (item) { const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'builder-editor-tab'; button.dataset.builderTab = item.key; button.setAttribute( 'role', 'tab' ); button.textContent = item.label; button.addEventListener( 'click', function () { activatePanel( item.key ); } ); tabBar.appendChild( button ); } ); /* * Insert the new structure after hidden inputs so all form fields remain * part of the same form and submit exactly as before. */ form.appendChild( tabBar ); availablePanels.forEach( function (item) { form.appendChild( panels[item.key] ); } ); if (saveContainer) { saveContainer.classList.add( 'builder-save-bar' ); form.appendChild( saveContainer ); } const initialPanelKey = availablePanels.some( function (item) { return item.key === requestedTab; } ) ? requestedTab : 'basics'; activatePanel( initialPanelKey ); } initializeBuilderEditorTabs(); // ================================================================ // SHARED GAMEPLAY TICK EFFECTS CONTROLS v25 // WAYFARER_GAMEPLAY_MODULAR_V25 // ================================================================ function initializeTickEffects() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } const wrapper = editor.querySelector( '[data-builder-tick-effects]' ); if (!wrapper) { return; } if ( wrapper.dataset.tickEffectsReady === '1' ) { return; } wrapper.dataset.tickEffectsReady = '1'; // ------------------------------------------------------------ // ROW ADD / REMOVE // ------------------------------------------------------------ const list = wrapper.querySelector( '[data-tick-effects-list]' ); const addButton = wrapper.querySelector( '[data-add-tick-effect]' ); const template = wrapper.querySelector( '[data-tick-effect-template]' ); let nextIndex = 0; wrapper .querySelectorAll( '[data-tick-effect-row]' ) .forEach( function (row) { const index = Number( row.dataset.tickIndex ); if ( Number.isFinite(index) && index >= nextIndex ) { nextIndex = index + 1; } } ); function updateLinearTargetAvailability() { const navigationSelect = editor.querySelector( 'select[name="navigation_type"]' ); const navigationType = navigationSelect ? navigationSelect.value : wrapper.dataset.navigationType; const linear = navigationType === 'linear'; wrapper .querySelectorAll( '[data-linear-navigation-target]' ) .forEach( function (option) { option.disabled = !linear; } ); if (!linear) { wrapper .querySelectorAll( '[data-tick-effect-target]' ) .forEach( function (select) { if ( select.value === 'navigation:position' ) { select.value = ''; } } ); } } if ( addButton && list && template ) { addButton.addEventListener( 'click', function () { const html = template.innerHTML.replace( /__INDEX__/g, String( nextIndex++ ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const row = temporary.firstElementChild; if (!row) { return; } list.appendChild( row ); updateLinearTargetAvailability(); } ); } wrapper.addEventListener( 'click', function (event) { const removeButton = event.target.closest( '[data-remove-tick-effect]' ); if (!removeButton) { return; } const row = removeButton.closest( '[data-tick-effect-row]' ); if (row) { row.remove(); } } ); const navigationSelect = editor.querySelector( 'select[name="navigation_type"]' ); if (navigationSelect) { navigationSelect.addEventListener( 'change', updateLinearTargetAvailability ); } updateLinearTargetAvailability(); /* * WAYFARER_EMBEDDED_TICK_EFFECTS_V33 * * Game Settings already owns a Gameplay tab. In embedded mode we keep * this shared editor exactly where PHP rendered it while retaining the * normal Tick Effect, condition, and result-branch controls. */ if ( wrapper.hasAttribute( 'data-tick-effects-embedded' ) ) { return; } // ------------------------------------------------------------ // MOVE THE SECTION INTO ITS OWN BUILDER TAB // ------------------------------------------------------------ const form = wrapper.closest( 'form' ); if (!form) { return; } const tabBar = form.querySelector( '.builder-editor-tabs' ); if (!tabBar) { /* * Builder without the v10 tab layout: * leave the Tick Effects section inline. */ return; } if ( form.querySelector( '[data-builder-panel="tick_effects"]' ) ) { return; } const panel = document.createElement( 'div' ); panel.className = 'builder-tab-panel'; panel.dataset.builderPanel = 'tick_effects'; panel.hidden = true; panel.appendChild( wrapper ); const saveBar = form.querySelector( '.builder-save-bar' ); if (saveBar) { form.insertBefore( panel, saveBar ); } else { form.appendChild( panel ); } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'builder-editor-tab'; button.dataset.builderTab = 'tick_effects'; button.setAttribute( 'role', 'tab' ); button.setAttribute( 'aria-selected', 'false' ); /* WAYFARER_GAMEPLAY_TAB_LABEL_V25_1 */ button.textContent = 'Effects'; tabBar.appendChild( button ); function activateTickEffectsTab() { tabBar .querySelectorAll( '[data-builder-tab]' ) .forEach( function (tabButton) { const active = tabButton === button; tabButton.classList.toggle( 'is-active', active ); tabButton.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); form .querySelectorAll( '[data-builder-panel]' ) .forEach( function (tabPanel) { tabPanel.hidden = tabPanel.dataset.builderPanel !== 'tick_effects'; } ); } button.addEventListener( 'click', activateTickEffectsTab ); /* * The original tab controller does not know about the panel added * afterward, so explicitly hide our panel whenever an original tab * is clicked. */ tabBar .querySelectorAll( '[data-builder-tab]:not([data-builder-tab="tick_effects"])' ) .forEach( function (originalButton) { originalButton.addEventListener( 'click', function () { panel.hidden = true; button.classList.remove( 'is-active' ); button.setAttribute( 'aria-selected', 'false' ); } ); } ); } // ================================================================ // SHARED ONE-TIME EFFECTS CONTROLS // WAYFARER_ONE_TIME_EFFECTS_EDITOR_V42 // WAYFARER_GENERALIZED_EFFECT_EDITOR_V43 // ================================================================ function wayfarerEffectConditionTargets( wrapper ) { const targetJson = wrapper.querySelector( '[data-effect-condition-targets-json]' ); if (!targetJson) { return {}; } try { return JSON.parse( targetJson.textContent || '{}' ); } catch (error) { return {}; } } /* WAYFARER_DATATYPE_EFFECT_UI_V58 */ function wayfarerEffectTargetCatalog( wrapper ) { const targetJson = wrapper ? wrapper.querySelector( '[data-effect-target-catalog-json]' ) : null; if (!targetJson) { return {}; } try { return JSON.parse( targetJson.textContent || '{}' ); } catch (error) { return {}; } } function wayfarerEffectTargetDefinition( branch ) { const effectRow = branch ? branch.closest( '[data-effect-row]' ) : null; const wrapper = effectRow ? effectRow.closest( '[data-builder-effects]' ) : null; const targetSelect = effectRow ? effectRow.querySelector( '[data-effect-target]' ) : null; const catalog = wayfarerEffectTargetCatalog( wrapper ); return targetSelect ? (catalog[targetSelect.value] || {}) : {}; } function wayfarerEffectEnsureChoiceSelect( fixedWrap, numberInput ) { if (!fixedWrap || !numberInput) { return null; } let select = fixedWrap.querySelector( '[data-effect-fixed-choice]' ); if (!select) { select = document.createElement( 'select' ); select.dataset.effectFixedChoice = '1'; select.name = numberInput.name; select.hidden = true; select.disabled = true; fixedWrap.appendChild( select ); } return select; } function wayfarerEffectBuildConditionEditor( host, name, targets ) { const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( name, targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } function wayfarerEffectUpdateResultFields( branch ) { const operation = branch.querySelector( '[data-effect-branch-operation]' ); const modeWrap = branch.querySelector( '[data-effect-value-mode-wrap]' ); const mode = branch.querySelector( '[data-effect-value-mode]' ); const fixedWrap = branch.querySelector( '[data-effect-fixed-wrap]' ); const randomWrap = branch.querySelector( '[data-effect-random-wrap]' ); if (!operation) { return; } const definition = wayfarerEffectTargetDefinition( branch ); let dataType = String( definition.data_type || definition.trait_type || 'number' ).toLowerCase(); if (dataType === 'binary') { dataType = 'boolean'; } const semanticType = String( definition.semantic_type || 'trait' ); const allowedOperations = Array.isArray( definition.effect_operations ) && definition.effect_operations.length ? definition.effect_operations : ( dataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); const supportsRandom = Object.prototype.hasOwnProperty.call( definition, 'supports_random' ) ? Boolean( definition.supports_random ) : dataType === 'number'; const operationLabels = { set: 'Set', add: semanticType === 'item_quantity' ? 'Add' : 'Add', subtract: semanticType === 'item_quantity' ? 'Subtract' : 'Subtract', none: 'No Change' }; const previousOperation = operation.value; const resultOperations = allowedOperations.concat( ['none'] ); operation.innerHTML = ''; resultOperations.forEach( function (operationValue) { const option = document.createElement( 'option' ); option.value = operationValue; option.textContent = operationLabels[operationValue] || operationValue; operation.appendChild( option ); } ); operation.value = resultOperations.includes( previousOperation ) ? previousOperation : allowedOperations[0]; const noChange = operation.value === 'none'; let valueMode = mode ? mode.value : 'fixed'; if (!supportsRandom) { valueMode = 'fixed'; if (mode) { mode.value = 'fixed'; } } if (modeWrap) { modeWrap.hidden = noChange || !supportsRandom; modeWrap .querySelectorAll( 'select, input' ) .forEach( function (field) { field.disabled = noChange || !supportsRandom; } ); } const showFixed = !noChange && ( !supportsRandom || valueMode === 'fixed' ); if (fixedWrap) { fixedWrap.hidden = !showFixed; const numberInput = fixedWrap.querySelector( 'input[name]' ); const choiceSelect = wayfarerEffectEnsureChoiceSelect( fixedWrap, numberInput ); const valueOptions = definition.value_options && typeof definition.value_options === 'object' ? definition.value_options : null; if ( showFixed && valueOptions && numberInput && choiceSelect ) { const previousValue = choiceSelect.hidden ? numberInput.value : choiceSelect.value; choiceSelect.innerHTML = ''; Object .entries(valueOptions) .forEach( function (entry) { const option = document.createElement( 'option' ); option.value = entry[0]; option.textContent = entry[1]; choiceSelect.appendChild( option ); } ); if ( Object.prototype.hasOwnProperty.call( valueOptions, String(previousValue) ) ) { choiceSelect.value = String(previousValue); } choiceSelect.hidden = false; choiceSelect.disabled = false; numberInput.hidden = true; numberInput.disabled = true; } else { if ( choiceSelect && numberInput && !choiceSelect.hidden ) { numberInput.value = choiceSelect.value; } if (choiceSelect) { choiceSelect.hidden = true; choiceSelect.disabled = true; } if (numberInput) { numberInput.hidden = !showFixed; numberInput.disabled = !showFixed; numberInput.type = dataType === 'number' ? 'number' : 'text'; if (dataType === 'number') { numberInput.step = 'any'; } else { numberInput.removeAttribute( 'step' ); } } } } if (randomWrap) { const showRandom = !noChange && supportsRandom && valueMode === 'random'; randomWrap.hidden = !showRandom; randomWrap .querySelectorAll( 'input' ) .forEach( function (field) { field.disabled = !showRandom; } ); } } function wayfarerEffectReindexBranches( effectRow ) { const effectIndex = effectRow.dataset.effectIndex; const list = effectRow.querySelector( '[data-effect-branch-list]' ); if ( effectIndex === undefined || !list ) { return; } const branches = Array .from( list.children ) .filter( function (child) { return ( child.matches && child.matches( '[data-effect-branch]' ) ); } ); branches.forEach( function ( branch, branchIndex ) { branch.dataset.branchIndex = String( branchIndex ); branch .querySelectorAll( '[name]' ) .forEach( function (field) { field.name = field.name.replace( /effects\[\d+\]\[branches\]\[\d+\]/, 'effects[' + effectIndex + '][branches][' + branchIndex + ']' ); } ); } ); } function wayfarerEffectRefreshBranchControls( effectRow ) { const list = effectRow.querySelector( '[data-effect-branch-list]' ); if (!list) { return; } const branches = Array .from( list.children ) .filter( function (child) { return ( child.matches && child.matches( '[data-effect-branch]' ) ); } ); const conditionals = branches.filter( function (branch) { return ( branch.dataset.branchKind === 'conditional' ); } ); conditionals.forEach( function ( branch, index ) { const up = branch.querySelector( '[data-move-effect-branch-up]' ); const down = branch.querySelector( '[data-move-effect-branch-down]' ); if (up) { up.disabled = index === 0; } if (down) { down.disabled = index === conditionals.length - 1; } } ); const hasOtherwise = branches.some( function (branch) { return ( branch.dataset.branchKind === 'otherwise' ); } ); const addOtherwise = effectRow.querySelector( '[data-add-effect-otherwise]' ); if (addOtherwise) { addOtherwise.disabled = hasOtherwise; } branches.forEach( wayfarerEffectUpdateResultFields ); wayfarerEffectReindexBranches( effectRow ); } function initializeEffects() { document .querySelectorAll( '[data-builder-effects]' ) .forEach( function (wrapper) { if ( wrapper.dataset.effectsReady === '1' ) { return; } wrapper.dataset.effectsReady = '1'; const list = wrapper.querySelector( '[data-effects-list]' ); const addButton = wrapper.querySelector( '[data-add-effect]' ); const effectTemplate = wrapper.querySelector( '[data-effect-template]' ); const conditionalTemplate = wrapper.querySelector( '[data-effect-conditional-template]' ); const otherwiseTemplate = wrapper.querySelector( '[data-effect-otherwise-template]' ); const conditionTargets = wayfarerEffectConditionTargets( wrapper ); let nextIndex = 0; wrapper .querySelectorAll( '[data-effect-row]' ) .forEach( function (row) { const index = Number( row.dataset.effectIndex ); if ( Number.isFinite( index ) && index >= nextIndex ) { nextIndex = index + 1; } wayfarerEffectRefreshBranchControls( row ); } ); if ( addButton && list && effectTemplate ) { addButton.addEventListener( 'click', function () { const effectIndex = nextIndex++; const html = effectTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', String( effectIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const row = temporary.firstElementChild; if (!row) { return; } list.appendChild( row ); wayfarerEffectRefreshBranchControls( row ); } ); } wrapper.addEventListener( 'change', function (event) { const target = event.target.closest( '[data-effect-target]' ); if (target) { const effectRow = target.closest( '[data-effect-row]' ); if (effectRow) { wayfarerEffectRefreshBranchControls( effectRow ); } return; } const operation = event.target.closest( '[data-effect-branch-operation]' ); const valueMode = event.target.closest( '[data-effect-value-mode]' ); if ( !operation && !valueMode ) { return; } const branch = event.target.closest( '[data-effect-branch]' ); if (branch) { wayfarerEffectUpdateResultFields( branch ); } } ); wrapper.addEventListener( 'click', function (event) { const effectRow = event.target.closest( '[data-effect-row]' ); const removeEffect = event.target.closest( '[data-remove-effect]' ); if (removeEffect) { if (!effectRow) { return; } if ( !window.confirm( 'Remove this Effect?' ) ) { return; } effectRow.remove(); return; } if (!effectRow) { return; } const addConditional = event.target.closest( '[data-add-effect-conditional]' ); if (addConditional) { if (!conditionalTemplate) { return; } const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if (!branchList) { return; } const effectIndex = effectRow.dataset.effectIndex; const branchIndex = branchList .querySelectorAll( ':scope > [data-effect-branch]' ) .length; const html = conditionalTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } const otherwise = Array .from( branchList.children ) .find( function (child) { return ( child.dataset && child.dataset.branchKind === 'otherwise' ); } ); if (otherwise) { branchList.insertBefore( branch, otherwise ); } else { branchList.appendChild( branch ); } wayfarerEffectReindexBranches( effectRow ); const newIndex = branch.dataset.branchIndex; const host = branch.querySelector( '[data-new-effect-condition-editor]' ); if (host) { wayfarerEffectBuildConditionEditor( host, 'effects[' + effectIndex + '][branches][' + newIndex + '][conditions]', conditionTargets ); } wayfarerEffectRefreshBranchControls( effectRow ); return; } const addOtherwise = event.target.closest( '[data-add-effect-otherwise]' ); if (addOtherwise) { if (!otherwiseTemplate) { return; } const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if ( !branchList || branchList.querySelector( '[data-branch-kind="otherwise"]' ) ) { return; } const effectIndex = effectRow.dataset.effectIndex; const branchIndex = branchList .querySelectorAll( ':scope > [data-effect-branch]' ) .length; const html = otherwiseTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } branchList.appendChild( branch ); wayfarerEffectRefreshBranchControls( effectRow ); return; } const removeBranch = event.target.closest( '[data-remove-effect-branch]' ); if (removeBranch) { const branch = removeBranch.closest( '[data-effect-branch]' ); if (branch) { branch.remove(); wayfarerEffectRefreshBranchControls( effectRow ); } return; } const moveUp = event.target.closest( '[data-move-effect-branch-up]' ); const moveDown = event.target.closest( '[data-move-effect-branch-down]' ); if ( !moveUp && !moveDown ) { return; } const branch = event.target.closest( '[data-effect-branch]' ); const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if ( !branch || !branchList || branch.dataset.branchKind !== 'conditional' ) { return; } const conditionals = Array .from( branchList.children ) .filter( function (child) { return ( child.dataset && child.dataset.branchKind === 'conditional' ); } ); const index = conditionals.indexOf( branch ); if ( moveUp && index > 0 ) { branchList.insertBefore( branch, conditionals[ index - 1 ] ); } if ( moveDown && index >= 0 && index < conditionals.length - 1 ) { const next = conditionals[ index + 1 ]; branchList.insertBefore( next, branch ); } wayfarerEffectRefreshBranchControls( effectRow ); } ); } ); } initializeEffects(); initializeTickEffects();initializeTickEffects(); // ================================================================ // REUSABLE CONDITION EDITOR v27 // WAYFARER_CONDITION_EDITOR_V27 // ================================================================ function wayfarerConditionEscape(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function wayfarerConditionTargetOptions( targets ) { const groups = {}; Object .entries( targets || {} ) .forEach( function (entry) { const key = entry[0]; const target = entry[1] || {}; /* * WAYFARER_LOCATION_CONDITION_TARGETS_V75 * WAYFARER_BUILDER_JS_REPAIR_V79 * * Hidden compatibility targets are retained in JSON so an * existing saved row can resolve its datatype, but they are * not offered when the author adds a new Condition. */ if (target.condition_hidden) { return; } const group = target.group || 'Targets'; if (!groups[group]) { groups[group] = []; } const helper = String( target.helper || '' ).trim(); const baseLabel = target.label || key; groups[group].push({ key: key, label: helper !== '' ? baseLabel + ' — ' + helper : baseLabel }); } ); let html = ''; Object .entries(groups) .forEach( function (entry) { html += ''; entry[1] .forEach( function (target) { html += ''; } ); html += ''; } ); return html; } /* WAYFARER_DATATYPE_CONDITION_UI_V58 */ function wayfarerConditionOperatorLabel( operator ) { const labels = { equals: 'Equals', not_equals: 'Does not equal', greater_than: 'Greater than', greater_than_or_equal: 'Greater than or equal', less_than: 'Less than', less_than_or_equal: 'Less than or equal' }; return labels[operator] || operator; } function wayfarerConditionUpdateRow( row, targets ) { if (!row) { return; } const targetSelect = row.querySelector( '[data-condition-target]' ); const operatorSelect = row.querySelector( '[data-condition-operator]' ); const valueInput = row.querySelector( '[data-condition-value-input]' ); const valueSelect = row.querySelector( '[data-condition-value-select]' ); const valueWrap = row.querySelector( '[data-condition-value-wrap]' ); if (!targetSelect || !operatorSelect) { return; } const definition = (targets || {})[targetSelect.value] || {}; const dataType = String( definition.data_type || definition.trait_type || 'text' ).toLowerCase() === 'binary' ? 'boolean' : String( definition.data_type || definition.trait_type || 'text' ).toLowerCase(); const allowedOperators = Array.isArray( definition.condition_operators ) && definition.condition_operators.length ? definition.condition_operators : [ 'equals', 'not_equals', 'greater_than', 'greater_than_or_equal', 'less_than', 'less_than_or_equal' ]; const previousOperator = operatorSelect.value; operatorSelect.innerHTML = ''; allowedOperators.forEach( function (operator) { const option = document.createElement( 'option' ); option.value = operator; const operatorLabels = definition.condition_operator_labels && typeof definition.condition_operator_labels === 'object' ? definition.condition_operator_labels : {}; option.textContent = operatorLabels[operator] || wayfarerConditionOperatorLabel( operator ); operatorSelect.appendChild( option ); } ); operatorSelect.value = allowedOperators.includes( previousOperator ) ? previousOperator : allowedOperators[0]; const valueOptions = definition.value_options && typeof definition.value_options === 'object' ? definition.value_options : null; if (valueWrap) { valueWrap.hidden = Boolean( definition.condition_hide_value ); } if (valueOptions && valueSelect) { const previousValue = valueSelect.hidden && valueInput ? valueInput.value : valueSelect.value; valueSelect.innerHTML = ''; Object .entries(valueOptions) .forEach( function (entry) { const option = document.createElement( 'option' ); option.value = entry[0]; option.textContent = entry[1]; valueSelect.appendChild( option ); } ); if ( Object.prototype.hasOwnProperty.call( valueOptions, String(previousValue) ) ) { valueSelect.value = String(previousValue); } valueSelect.hidden = false; valueSelect.disabled = false; if (valueInput) { valueInput.hidden = true; valueInput.disabled = true; } return; } if ( valueSelect && valueInput && !valueSelect.hidden ) { valueInput.value = valueSelect.value; } if (valueSelect) { valueSelect.hidden = true; valueSelect.disabled = true; } if (valueInput) { valueInput.hidden = false; valueInput.disabled = false; valueInput.type = dataType === 'number' ? 'number' : 'text'; if (dataType === 'number') { valueInput.step = 'any'; } else { valueInput.removeAttribute( 'step' ); } } } function wayfarerConditionNextChildIndex( group ) { let maxIndex = -1; const children = Array .from( group.children ) .find( function (child) { return child.hasAttribute( 'data-condition-children' ); } ); if (children) { Array .from( children.children ) .forEach( function (node) { let index = Number( node.dataset.conditionChildIndex ); if (!Number.isFinite(index)) { const namedInput = node.querySelector( '[name]' ); const match = namedInput ? namedInput.name.match( /\[children\]\[(\d+)\]/ ) : null; index = match ? Number(match[1]) : -1; } if ( Number.isFinite(index) && index > maxIndex ) { maxIndex = index; } } ); } return maxIndex + 1; } function wayfarerConditionCreateCondition( name, targets, childIndex ) { const row = document.createElement( 'div' ); row.className = 'builder-condition-row'; row.dataset.conditionNode = '1'; row.dataset.conditionType = 'condition'; row.dataset.conditionChildIndex = String(childIndex); row.innerHTML = '' + '' + '' + '' + ''; return row; } function wayfarerConditionCreateGroup( name, targets, childIndex, isRoot ) { const group = document.createElement( 'div' ); group.className = 'builder-condition-group' + ( isRoot ? ' builder-condition-group-root' : '' ); group.dataset.conditionNode = '1'; group.dataset.conditionType = 'group'; if ( childIndex !== null && childIndex !== undefined ) { group.dataset.conditionChildIndex = String(childIndex); } group.innerHTML = '' + '
' + '' + ' of the following' + ( isRoot ? '' : '' ) + '
' + '
' + '
' + ' ' + '' + '
'; return group; } function wayfarerConditionInitializeEditor( editor, targets ) { if ( !editor || editor.dataset.conditionEditorReady === '1' ) { return; } editor.dataset.conditionEditorReady = '1'; editor.addEventListener( 'change', function (event) { const targetSelect = event.target.closest( '[data-condition-target]' ); if (!targetSelect) { return; } wayfarerConditionUpdateRow( targetSelect.closest( '[data-condition-type="condition"]' ), targets ); } ); editor .querySelectorAll( '[data-condition-type="condition"]' ) .forEach( function (row) { wayfarerConditionUpdateRow( row, targets ); } ); editor.addEventListener( 'click', function (event) { const removeButton = event.target.closest( '[data-remove-condition-node]' ); if (removeButton) { const node = removeButton.closest( '[data-condition-node]' ); if (node) { node.remove(); } return; } const addCondition = event.target.closest( '[data-add-condition]' ); const addGroup = event.target.closest( '[data-add-condition-group]' ); if ( !addCondition && !addGroup ) { return; } const group = event.target.closest( '[data-condition-type="group"]' ); if (!group) { return; } const children = Array .from( group.children ) .find( function (child) { return child.hasAttribute( 'data-condition-children' ); } ); if (!children) { return; } const groupTypeInput = Array .from( group.children ) .find( function (child) { return ( child.tagName === 'INPUT' && child.type === 'hidden' && child.name.endsWith( '[type]' ) ); } ); if (!groupTypeInput) { return; } const groupName = groupTypeInput.name.replace( /\[type\]$/, '' ); const nextIndex = wayfarerConditionNextChildIndex( group ); const childName = groupName + '[children][' + nextIndex + ']'; const newNode = addGroup ? wayfarerConditionCreateGroup( childName, targets, nextIndex, false ) : wayfarerConditionCreateCondition( childName, targets, nextIndex ); children.appendChild( newNode ); if (newNode.dataset.conditionType === 'condition') { wayfarerConditionUpdateRow( newNode, targets ); } } ); } function initializeConditionEditors() { /* * WAYFARER_GENERIC_CONDITION_INIT_V45_2 * * The Condition Editor is shared infrastructure. It must work anywhere it * is rendered, not only when nested inside Tick Effects. * * Server-rendered editors carry their own allowed-target JSON. Dynamic * Tick Effect / Effect editors continue to initialize themselves through * their existing creation paths. */ document .querySelectorAll( '[data-condition-editor]' ) .forEach( function (editor) { let targets = {}; const targetJson = editor.querySelector( '[data-condition-editor-targets-json]' ); if (targetJson) { try { targets = JSON.parse( targetJson.textContent || '{}' ); } catch (error) { targets = {}; } } wayfarerConditionInitializeEditor( editor, targets ); } ); /* * Tick Effects retain their observer because newly-created Tick Effect * rows are added after page load and are not server-rendered editors. */ document .querySelectorAll( '[data-builder-tick-effects]' ) .forEach( function (wrapper) { let targets = {}; const targetJson = wrapper.querySelector( '[data-condition-targets-json]' ); if (targetJson) { try { targets = JSON.parse( targetJson.textContent || '{}' ); } catch (error) { targets = {}; } } wrapper .querySelectorAll( '[data-condition-editor]' ) .forEach( function (editor) { wayfarerConditionInitializeEditor( editor, targets ); } ); /* * New Tick Effect rows are cloned from a template after page * load, so convert their placeholder into a fresh root group. */ const list = wrapper.querySelector( '[data-tick-effects-list]' ); if ( list && !list.dataset.conditionObserverReady ) { list.dataset.conditionObserverReady = '1'; const observer = new MutationObserver( function (mutations) { mutations.forEach( function (mutation) { mutation.addedNodes .forEach( function (addedNode) { if ( !addedNode.querySelectorAll ) { return; } const row = addedNode.matches && addedNode.matches( '[data-tick-effect-row]' ) ? addedNode : addedNode.querySelector( '[data-tick-effect-row]' ); if (!row) { return; } const host = row.querySelector( '[data-new-condition-editor]' ); if (!host) { return; } const index = row.dataset.tickIndex; const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( 'tick_effects[' + index + '][conditions]', targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } ); } ); } ); observer.observe( list, { childList: true, subtree: true } ); } } ); } initializeConditionEditors(); // ================================================================ // TICK EFFECT RESULT BRANCHES v28 // WAYFARER_TICK_EFFECT_BRANCHES_V28 // ================================================================ function wayfarerTickEffectConditionTargets( wrapper ) { const targetJson = wrapper.querySelector( '[data-condition-targets-json]' ); if (!targetJson) { return {}; } try { return JSON.parse( targetJson.textContent || '{}' ); } catch (error) { return {}; } } /* WAYFARER_DATATYPE_TICK_EFFECT_UI_V58 */ function wayfarerTickEffectTargetCatalog( wrapper ) { const targetJson = wrapper ? wrapper.querySelector( '[data-tick-target-catalog-json]' ) : null; if (!targetJson) { return {}; } try { return JSON.parse( targetJson.textContent || '{}' ); } catch (error) { return {}; } } function wayfarerTickEffectTargetDefinition( branch ) { const effectRow = branch ? branch.closest( '[data-tick-effect-row]' ) : null; const wrapper = effectRow ? effectRow.closest( '[data-builder-tick-effects]' ) : null; const targetSelect = effectRow ? effectRow.querySelector( '[data-tick-effect-target]' ) : null; const catalog = wayfarerTickEffectTargetCatalog( wrapper ); return targetSelect ? (catalog[targetSelect.value] || {}) : {}; } function wayfarerTickEffectEnsureValueMode( branch, valueInput ) { if (!branch || !valueInput) { return null; } let wrap = branch.querySelector( '[data-tick-value-mode-wrap]' ); if (!wrap) { wrap = document.createElement( 'label' ); wrap.dataset.tickValueModeWrap = '1'; wrap.appendChild( document.createTextNode( 'Value Type' ) ); const select = document.createElement( 'select' ); select.dataset.tickValueMode = '1'; select.name = valueInput.name.replace( /\[value\]$/, '[value_mode]' ); select.innerHTML = '' + ''; const fields = valueInput.closest( '.builder-tick-result-fields' ); if ( fields && fields.dataset.tickInitialValueMode === 'random' ) { select.value = 'random'; } wrap.appendChild( select ); const valueWrap = branch.querySelector( '[data-tick-branch-value-wrap]' ); if (valueWrap && valueWrap.parentNode) { valueWrap.parentNode.insertBefore( wrap, valueWrap ); } } return wrap; } function wayfarerTickEffectEnsureChoiceSelect( valueWrap, valueInput ) { if (!valueWrap || !valueInput) { return null; } let select = valueWrap.querySelector( '[data-tick-fixed-choice]' ); if (!select) { select = document.createElement( 'select' ); select.dataset.tickFixedChoice = '1'; select.name = valueInput.name; select.hidden = true; select.disabled = true; valueWrap.appendChild( select ); } return select; } function wayfarerTickEffectEnsureRandomWrap( branch, valueInput ) { if (!branch || !valueInput) { return null; } let wrap = branch.querySelector( '[data-tick-random-wrap]' ); if (!wrap) { wrap = document.createElement( 'div' ); wrap.className = 'builder-effect-random-range'; wrap.dataset.tickRandomWrap = '1'; const baseName = valueInput.name.replace( /\[value\]$/, '' ); wrap.innerHTML = '' + ''; const fields = valueInput.closest( '.builder-tick-result-fields' ); if (fields) { const minInput = wrap.querySelector( '[data-tick-random-min]' ); const maxInput = wrap.querySelector( '[data-tick-random-max]' ); if (minInput) { minInput.value = fields.dataset.tickInitialMin || ''; } if (maxInput) { maxInput.value = fields.dataset.tickInitialMax || ''; } } const valueWrap = branch.querySelector( '[data-tick-branch-value-wrap]' ); if (valueWrap && valueWrap.parentNode) { valueWrap.parentNode.insertBefore( wrap, valueWrap.nextSibling ); } } return wrap; } function wayfarerTickEffectUpdateBranchValue( branch ) { const operation = branch.querySelector( '[data-tick-branch-operation]' ); const valueWrap = branch.querySelector( '[data-tick-branch-value-wrap]' ); if ( !operation || !valueWrap ) { return; } const input = valueWrap.querySelector( 'input[name]' ); if (!input) { return; } const definition = wayfarerTickEffectTargetDefinition( branch ); let dataType = String( definition.data_type || definition.trait_type || 'number' ).toLowerCase(); if (dataType === 'binary') { dataType = 'boolean'; } const allowedOperations = Array.isArray( definition.effect_operations ) && definition.effect_operations.length ? definition.effect_operations : ( dataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); const supportsRandom = Object.prototype.hasOwnProperty.call( definition, 'supports_random' ) ? Boolean( definition.supports_random ) : dataType === 'number'; const previousOperation = operation.value; const resultOperations = allowedOperations.concat( ['none'] ); operation.innerHTML = ''; const operationLabels = { set: 'Set', add: 'Add', subtract: 'Subtract', none: 'No Change' }; resultOperations.forEach( function (operationValue) { const option = document.createElement( 'option' ); option.value = operationValue; option.textContent = operationLabels[operationValue] || operationValue; operation.appendChild( option ); } ); operation.value = resultOperations.includes( previousOperation ) ? previousOperation : allowedOperations[0]; const noChange = operation.value === 'none'; const modeWrap = wayfarerTickEffectEnsureValueMode( branch, input ); const mode = modeWrap ? modeWrap.querySelector( '[data-tick-value-mode]' ) : null; let valueMode = mode ? mode.value : 'fixed'; if (!supportsRandom) { valueMode = 'fixed'; if (mode) { mode.value = 'fixed'; } } if (modeWrap) { modeWrap.hidden = noChange || !supportsRandom; if (mode) { mode.disabled = noChange || !supportsRandom; } } const showFixed = !noChange && ( !supportsRandom || valueMode === 'fixed' ); valueWrap.hidden = !showFixed; const choiceSelect = wayfarerTickEffectEnsureChoiceSelect( valueWrap, input ); const valueOptions = definition.value_options && typeof definition.value_options === 'object' ? definition.value_options : null; if ( showFixed && valueOptions && choiceSelect ) { const previousValue = choiceSelect.hidden ? input.value : choiceSelect.value; choiceSelect.innerHTML = ''; Object .entries(valueOptions) .forEach( function (entry) { const option = document.createElement( 'option' ); option.value = entry[0]; option.textContent = entry[1]; choiceSelect.appendChild( option ); } ); if ( Object.prototype.hasOwnProperty.call( valueOptions, String(previousValue) ) ) { choiceSelect.value = String(previousValue); } choiceSelect.hidden = false; choiceSelect.disabled = false; input.hidden = true; input.disabled = true; } else { if ( choiceSelect && !choiceSelect.hidden ) { input.value = choiceSelect.value; } if (choiceSelect) { choiceSelect.hidden = true; choiceSelect.disabled = true; } input.hidden = !showFixed; input.disabled = !showFixed; input.type = dataType === 'number' ? 'number' : 'text'; if (dataType === 'number') { input.step = 'any'; } else { input.removeAttribute( 'step' ); } } const randomWrap = wayfarerTickEffectEnsureRandomWrap( branch, input ); if (randomWrap) { const showRandom = !noChange && supportsRandom && valueMode === 'random'; randomWrap.hidden = !showRandom; randomWrap .querySelectorAll( 'input' ) .forEach( function (field) { field.disabled = !showRandom; } ); } } function wayfarerTickEffectReindexBranches( effectRow ) { const effectIndex = effectRow.dataset.tickIndex; const list = effectRow.querySelector( '[data-tick-branch-list]' ); if ( effectIndex === undefined || !list ) { return; } const branches = Array.from( list.children ).filter( function (child) { return ( child.matches && child.matches( '[data-tick-effect-branch]' ) ); } ); branches.forEach( function ( branch, branchIndex ) { branch.dataset.branchIndex = String( branchIndex ); branch .querySelectorAll( '[name]' ) .forEach( function (field) { field.name = field.name.replace( /tick_effects\[\d+\]\[branches\]\[\d+\]/, 'tick_effects[' + effectIndex + '][branches][' + branchIndex + ']' ); } ); } ); } function wayfarerTickEffectRefreshBranchControls( effectRow ) { const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } const branches = Array.from( list.children ).filter( function (child) { return ( child.matches && child.matches( '[data-tick-effect-branch]' ) ); } ); const conditionalBranches = branches.filter( function (branch) { return ( branch.dataset.branchKind === 'conditional' ); } ); conditionalBranches.forEach( function ( branch, index ) { const up = branch.querySelector( '[data-move-tick-branch-up]' ); const down = branch.querySelector( '[data-move-tick-branch-down]' ); if (up) { up.disabled = index === 0; } if (down) { down.disabled = index === conditionalBranches.length - 1; } } ); const hasOtherwise = branches.some( function (branch) { return ( branch.dataset.branchKind === 'otherwise' ); } ); const addOtherwise = effectRow.querySelector( '[data-add-tick-otherwise-result]' ); if (addOtherwise) { addOtherwise.disabled = hasOtherwise; } branches.forEach( wayfarerTickEffectUpdateBranchValue ); wayfarerTickEffectReindexBranches( effectRow ); } function wayfarerTickEffectBuildConditionEditor( host, name, targets ) { const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( name, targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } function initializeTickEffectResultBranches() { document .querySelectorAll( '[data-builder-tick-effects]' ) .forEach( function (wrapper) { if ( wrapper.dataset.tickBranchesReady === '1' ) { return; } wrapper.dataset.tickBranchesReady = '1'; const conditionalTemplate = wrapper.querySelector( '[data-tick-conditional-branch-template]' ); const otherwiseTemplate = wrapper.querySelector( '[data-tick-otherwise-branch-template]' ); const conditionTargets = wayfarerTickEffectConditionTargets( wrapper ); wrapper .querySelectorAll( '[data-tick-effect-row]' ) .forEach( wayfarerTickEffectRefreshBranchControls ); wrapper.addEventListener( 'change', function (event) { const target = event.target.closest( '[data-tick-effect-target]' ); if (target) { const effectRow = target.closest( '[data-tick-effect-row]' ); if (effectRow) { wayfarerTickEffectRefreshBranchControls( effectRow ); } return; } const operation = event.target.closest( '[data-tick-branch-operation]' ); const valueMode = event.target.closest( '[data-tick-value-mode]' ); if (!operation && !valueMode) { return; } const branch = event.target.closest( '[data-tick-effect-branch]' ); if (branch) { wayfarerTickEffectUpdateBranchValue( branch ); } } ); wrapper.addEventListener( 'click', function (event) { const effectRow = event.target.closest( '[data-tick-effect-row]' ); if (!effectRow) { return; } const addConditional = event.target.closest( '[data-add-tick-conditional-result]' ); if (addConditional) { if (!conditionalTemplate) { return; } const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } const effectIndex = effectRow.dataset.tickIndex; const branchIndex = list.querySelectorAll( ':scope > [data-tick-effect-branch]' ).length; const html = conditionalTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } const otherwise = Array .from( list.children ) .find( function (child) { return ( child.dataset && child.dataset.branchKind === 'otherwise' ); } ); if (otherwise) { list.insertBefore( branch, otherwise ); } else { list.appendChild( branch ); } wayfarerTickEffectReindexBranches( effectRow ); const newIndex = branch.dataset.branchIndex; const host = branch.querySelector( '[data-new-branch-condition-editor]' ); if (host) { wayfarerTickEffectBuildConditionEditor( host, 'tick_effects[' + effectIndex + '][branches][' + newIndex + '][conditions]', conditionTargets ); } wayfarerTickEffectRefreshBranchControls( effectRow ); return; } const addOtherwise = event.target.closest( '[data-add-tick-otherwise-result]' ); if (addOtherwise) { if (!otherwiseTemplate) { return; } const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } if ( list.querySelector( '[data-branch-kind="otherwise"]' ) ) { return; } const effectIndex = effectRow.dataset.tickIndex; const branchIndex = list.querySelectorAll( ':scope > [data-tick-effect-branch]' ).length; const html = otherwiseTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } list.appendChild( branch ); wayfarerTickEffectRefreshBranchControls( effectRow ); return; } const removeBranch = event.target.closest( '[data-remove-tick-branch]' ); if (removeBranch) { const branch = removeBranch.closest( '[data-tick-effect-branch]' ); if (branch) { branch.remove(); wayfarerTickEffectRefreshBranchControls( effectRow ); } return; } const moveUp = event.target.closest( '[data-move-tick-branch-up]' ); const moveDown = event.target.closest( '[data-move-tick-branch-down]' ); if ( !moveUp && !moveDown ) { return; } const branch = event.target.closest( '[data-tick-effect-branch]' ); const list = effectRow.querySelector( '[data-tick-branch-list]' ); if ( !branch || !list || branch.dataset.branchKind !== 'conditional' ) { return; } const conditionals = Array.from( list.children ).filter( function (child) { return ( child.dataset && child.dataset.branchKind === 'conditional' ); } ); const index = conditionals.indexOf( branch ); if ( moveUp && index > 0 ) { list.insertBefore( branch, conditionals[index - 1] ); } if ( moveDown && index >= 0 && index < conditionals.length - 1 ) { const next = conditionals[index + 1]; list.insertBefore( next, branch ); } wayfarerTickEffectRefreshBranchControls( effectRow ); } ); /* * Existing Tick Effect code creates whole effect rows from its * template. Refresh branch controls after those rows appear. */ const list = wrapper.querySelector( '[data-tick-effects-list]' ); if (list) { const observer = new MutationObserver( function (mutations) { mutations.forEach( function (mutation) { mutation.addedNodes .forEach( function (node) { if ( !node.matches || !node.matches( '[data-tick-effect-row]' ) ) { return; } wayfarerTickEffectRefreshBranchControls( node ); } ); } ); } ); observer.observe( list, { childList: true } ); } } ); } initializeTickEffectResultBranches(); ================================================================================ FILE: _wayfarer_backups/v88-20260929-205950/config/builder-types.php ================================================================================ [ 'label' => 'Regions', 'singular' => 'Region', 'table' => 'wf_regions', 'list_field' => 'name', 'traits' => true, /* * Permanent deletion is available from the edit screen. * A Region cannot be deleted while Locations still belong to it. */ 'delete' => [ 'enabled' => true, 'dependencies' => [ [ 'table' => 'wf_locations', 'foreign_key' => 'region_id', 'label' => 'Locations' ] ] ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'navigation_type' => [ 'label' => 'Navigation Type', 'type' => 'select', 'required' => true, 'options' => [ 'linear' => 'Linear', 'grid' => 'Grid', 'freeform' => 'Freeform' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], /* WAYFARER_LINEAR_DISTANCE_LABEL_V61 */ 'config_fields' => [ 'distance_label' => [ 'label' => 'Distance Label', 'type' => 'text', 'default' => 'units', 'help' => 'Player-facing unit used for Linear travel distance, such as miles, km, leagues, or units.' ] ] ], 'locations' => [ 'label' => 'Locations', 'singular' => 'Location', 'table' => 'wf_locations', 'list_field' => 'name', 'traits' => true, 'list_columns' => [ 'name' => [ 'label' => 'Name', 'source' => 'field', 'field' => 'name', 'sortable' => true ], 'region' => [ 'label' => 'Region', 'source' => 'relationship', 'relationship' => 'region_id', 'sortable' => true ], 'position' => [ 'label' => 'Position', 'source' => 'config', 'field' => 'position', 'type' => 'number', 'sortable' => true ] ], /* * Permanent deletion is available from the edit screen. */ 'delete' => [ 'enabled' => true ], 'scope' => [ 'type' => 'region', 'field' => 'region_id', 'scope_table' => 'wf_regions', 'scope_key' => 'id', 'game_field' => 'game_id' ], 'relationships' => [ 'region_id' => [ 'label' => 'Region', 'type' => 'select_record', 'table' => 'wf_regions', 'value_field' => 'id', 'label_field' => 'name', 'game_field' => 'game_id', 'required' => true ] ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], 'config_fields' => [ 'position' => [ 'label' => 'Position', 'type' => 'number', 'min' => 0, 'required' => true, 'condition' => [ 'relationship' => 'region_id', 'source_table' => 'wf_regions', 'source_key' => 'id', 'source_field' => 'navigation_type', 'equals' => 'linear' ] ] ] ], 'entities' => [ 'label' => 'Entities', 'singular' => 'Entity', 'table' => 'wf_entities', 'list_field' => 'name', 'traits' => true, /* * Permanent deletion is available from the edit screen. * Additional dependency rules can be added here later as * Storylets and other authored objects begin referencing Entities. */ 'delete' => [ 'enabled' => true ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'entity_type' => [ 'label' => 'Entity Type', 'type' => 'select', 'required' => true, 'default' => 'item', 'options' => [ 'item' => 'Item', 'character' => 'Character' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], /* * System traits use the same stored config.traits collection as * author-created traits. They are locked in the authoring UI. */ 'system_traits' => [ 'selector_field' => 'entity_type', 'after_field' => 'entity_type', 'groups' => [ 'item' => [ 'label' => 'Item Settings', 'help' => 'These are required Item properties used by Wayfarer. ' . 'They cannot be removed or renamed.', 'traits' => [ 'quantity' => [ /* * WAYFARER_ITEM_QUANTITY_CONTEXT_V44 */ 'label' => 'Starting Quantity', 'type' => 'number', 'default' => 0, 'min' => 0, 'required' => true, /* * The minimum is a Wayfarer system rule, not an * author-editable gameplay setting. */ 'show_system_min' => true, 'min_label' => 'Minimum Quantity', 'min_help' => 'Wayfarer Item quantities cannot fall below 0.', 'allow_author_max' => true, 'max_label' => 'Maximum Quantity', 'max_help' => 'The maximum quantity the player can hold during gameplay. ' . 'Leave blank for no maximum.', 'help' => 'Sets the quantity of this Item when gameplay is initialized. ' . 'Effects may increase or decrease the quantity during play. ' . 'A Starting Quantity of 0 means the player begins without this Item.' ], 'inventory_visibility' => [ 'label' => 'Inventory Visibility', 'type' => 'select', 'default' => 'while_possessed', 'required' => true, 'options' => [ 'while_possessed' => 'Only while possessed', 'once_discovered' => 'Once discovered', 'always' => 'Always', 'never' => 'Never' ], 'help' => 'Controls when this item appears in the player inventory. ' . '"Only while possessed" hides the item whenever its quantity ' . 'is 0. "Once discovered" hides it until the player first ' . 'obtains or discovers it, then keeps it visible even if its ' . 'quantity later returns to 0. "Always" shows the item from ' . 'the beginning of the game. "Never" keeps the item hidden ' . 'from the player inventory while still allowing gameplay ' . 'conditions and effects to use it.' ] ] ], 'character' => [ 'label' => 'Character Settings', 'help' => 'These are required Character properties used by Wayfarer. ' . 'They cannot be removed or renamed.', 'traits' => [ 'party_member' => [ 'label' => 'Starts in Party', 'type' => 'binary', 'default' => 0, 'required' => true, 'help' => 'Determines whether this character begins the game ' . 'as a member of the player party. No stores 0; ' . 'Yes stores 1. Storylets can later change this ' . 'state to add or remove the character from the party.' ] ] ] ] ] ], /* WAYFARER_STATUSES_V87 */ 'statuses' => [ 'label' => 'Statuses', 'singular' => 'Status', 'table' => 'wf_statuses', 'list_field' => 'name', 'list_columns' => [ 'name' => [ 'label' => 'Name', 'source' => 'field', 'field' => 'name', 'sortable' => true ], 'status' => [ 'label' => 'Status', 'source' => 'field', 'field' => 'status', 'sortable' => true ] ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true, 'help' => 'Author-facing and player-facing name of this persistent Status.' ], 'description' => [ 'label' => 'Description', 'type' => 'textarea', 'help' => 'Optional player-facing description shown while this Status is active.' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ], 'help' => 'Only Active Status definitions are available to gameplay Conditions and Effects.' ] ], 'config_fields' => [ 'show_to_player' => [ 'label' => 'Show to Player', 'type' => 'select', 'default' => '1', 'required' => true, 'options' => [ '1' => 'Yes', '0' => 'No' ], 'help' => 'If Yes, the Status appears beneath the affected party member on the player Status screen.' ] ] ], /* WAYFARER_STORYLET_BUILDER_V56 */ 'storylets' => [ 'label' => 'Storylets', 'singular' => 'Storylet', 'table' => 'wf_storylets', 'list_field' => 'name', 'list_columns' => [ 'name' => [ 'label' => 'Name', 'source' => 'field', 'field' => 'name', 'sortable' => true ], 'parent' => [ 'label' => 'Parent', 'source' => 'relationship', 'relationship' => 'parent_storylet_id', 'sortable' => true ], 'trigger' => [ 'label' => 'Trigger', 'source' => 'field', 'field' => 'trigger_type', 'sortable' => true ], 'status' => [ 'label' => 'Status', 'source' => 'field', 'field' => 'status', 'sortable' => true ] ], /* * A Storylet with children cannot be permanently removed until its * Branch Storylets have been detached or deleted. */ 'delete' => [ 'enabled' => true, 'dependencies' => [ [ 'table' => 'wf_storylets', 'foreign_key' => 'parent_storylet_id', 'label' => 'Branch Storylets' ] ] ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'relationships' => [ 'parent_storylet_id' => [ 'label' => 'Parent Storylet', 'type' => 'select_record', 'table' => 'wf_storylets', 'value_field' => 'id', 'label_field' => 'name', 'game_field' => 'game_id', 'required' => false, 'exclude_current' => true, 'help' => 'Leave blank for a root Storylet. Select a parent to make ' . 'this Storylet one of that Storylet\'s player Branches.' ] ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true, 'help' => 'Author-facing Storylet name and default player-facing title.' ], 'description' => [ 'label' => 'Narrative / Description', 'type' => 'textarea', 'help' => 'Narrative shown when this Storylet opens. It may also be ' . 'used as preview text when the Storylet is offered.' ], 'branch_label' => [ 'label' => 'Branch Label', 'type' => 'text', 'help' => 'Used when this Storylet appears as a child Branch. Leave ' . 'blank to use the Storylet Name.' ], 'branch_sort_order' => [ 'label' => 'Branch Order', 'type' => 'number', 'min' => 0, 'default' => 0, 'help' => 'Lower numbers appear first among sibling Branches. Ignored ' . 'for root Storylets.' ], 'trigger_type' => [ 'label' => 'Trigger Type', 'type' => 'select', 'required' => true, 'default' => 'manual', 'options' => [ 'manual' => 'Manual', 'automatic' => 'Automatic', 'chance' => 'Chance' ], 'help' => 'Manual: offer while eligible. Automatic: fire when it becomes ' . 'eligible. Chance: while eligible, roll the base chance each tick.' ], 'max_occurrences' => [ 'label' => 'Maximum Occurrences', 'type' => 'number', 'min' => 1, 'help' => 'Leave blank for unlimited occurrences.' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], 'config_fields' => [ 'priority' => [ 'label' => 'Priority', 'type' => 'number', 'default' => 100, 'help' => 'Author-controlled gameplay order for competing Storylets. ' . 'Lower numbers are evaluated first.' ], 'tick_cost' => [ 'label' => 'Tick Cost', 'type' => 'number', 'min' => 0, 'default' => 0, 'help' => 'How many normal gameplay ticks resolving this Storylet consumes. ' . '0 consumes no time; 1 or more runs the normal tick pipeline.' ], 'chance_percent' => [ 'label' => 'Base Chance %', 'type' => 'number', 'min' => 0, 'max' => 100, 'step' => 0.01, 'help' => 'Used only by Chance Storylets. Eligibility is checked first; ' . 'then this percentage is rolled on each gameplay tick.' ], 'interrupts_travel' => [ 'label' => 'Interrupts Travel', 'type' => 'select', 'default' => '0', 'options' => [ '0' => 'No', '1' => 'Yes' ], 'help' => 'Used by Automatic and Chance Storylets. If Yes, firing the ' . 'Storylet stops the current travel activity. Manual Storylets ' . 'are selected by the player and do not use this setting.' ] ] ] ]; ================================================================================ FILE: _wayfarer_backups/v88-20260929-205950/includes/builder/editor.php ================================================================================ $groupValues) { if (!is_array($groupValues)) { continue; } foreach ($groupValues as $traitName => $traitValue) { if (!isset($storedSystemTraitValues[$groupName][$traitName])) { $storedSystemTraitValues[$groupName][$traitName] = $traitValue; } } } } } $renderSystemTraits = function () use ( $record, $fields, $systemTraitsSelector, $systemTraitGroups, $storedSystemTraitValues, $storedSystemTraitMaxValues ) { if (!$systemTraitsSelector || empty($systemTraitGroups)) { return; } $selectedGroup = $record[$systemTraitsSelector] ?? ($fields[$systemTraitsSelector]['default'] ?? ''); ?>
$groupDefinition): ?>
>

$traitDefinition): ?>

>

Storylet duplicated. This copy is saved as Draft.

Copies the saved Storylet into a new Draft. If this is a Branch, the copy becomes a sibling Branch. Child Branches are not copied.
$relationship): ?> $field): ?>

Media

Optional. Reuse an existing asset from this game's Media Library, upload a new asset, or leave the slot empty.

$mediaDefinition): ?>

Maximum upload: .

Current: ()



No existing assets have been uploaded to this game yet.

$field): ?>





Traits

Traits are additional values associated with this that can change during play.


Danger Zone

Permanently delete this . This cannot be undone.

================================================================================ FILE: _wayfarer_backups/v88-20260929-205950/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects, array $storyletBindings = [], int $currentStoryletId = 0 ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $allowedTargets = array_replace( gameplayRuntimeStateTargets( $pdo, $gameId ), gameplayStoryletBindingEffectTargets( $pdo, $gameId, $storyletBindings ), gameplayStoryletNavigationEffectTargets( $pdo, $gameId, $currentStoryletId ) ); $result = gameplayBuildEffectsFromPost( $allowedTargets, $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); if (($result['error'] ?? '') !== '') { return $result; } $transitionCount = 0; foreach ( $result['effects'] ?? [] as $effect ) { if ( is_array($effect) && (string)( $effect['target_scope'] ?? '' ) === 'storylet' && (string)( $effect['target_type'] ?? '' ) === 'transition' ) { $transitionCount++; } } if ($transitionCount > 1) { return [ 'effects' => [], 'error' => 'A Storylet may have only one Storylet → Continue To Effect.', ]; } return $result; } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /* * WAYFARER_LINEAR_TRAVEL_SAVE_V72 * * Dedicated Linear Travel is deliberately opt-in. A null travel result means * the Region should continue using the legacy Position Tick Effect path. */ function buildRegionTravelFromPost( array $record, array $postedTravel ): array { $navigationType = strtolower( trim( (string)( $record['navigation_type'] ?? '' ) ) ); if ($navigationType !== 'linear') { return [ 'travel' => null, 'error' => '', ]; } if (empty($postedTravel['enabled'])) { return [ 'travel' => null, 'error' => '', ]; } $baseDistance = $postedTravel['base_distance'] ?? ''; if ( !is_numeric($baseDistance) || (float)$baseDistance <= 0 ) { return [ 'travel' => null, 'error' => 'Base Distance Per Tick must be greater than zero.', ]; } $baseDistance = (float)$baseDistance; if (floor($baseDistance) === $baseDistance) { $baseDistance = (int)$baseDistance; } return [ 'travel' => [ 'enabled' => true, 'base_distance' => $baseDistance, 'allow_turnaround' => !empty( $postedTravel[ 'allow_turnaround' ] ), ], 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Storylet duplication | WAYFARER_STORYLET_DUPLICATE_V83 |-------------------------------------------------------------------------- | | Duplicate the currently saved Storylet as a Draft. The duplicate copies | the selected Storylet record and its media links, but intentionally does | not recursively copy child Branch Storylets. | */ function duplicateStoryletRecord( PDO $pdo, int $gameId, int $storyletId ): array { if ($gameId <= 0 || $storyletId <= 0) { return [ 'duplicated' => false, 'id' => 0, 'error' => 'A valid Storylet is required to duplicate.', ]; } try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, uuid, game_id, name, storylet_key, description, conditions, effects, max_occurrences, trigger_type, status, config, parent_storylet_id, choice_label, choice_sort_order, branch_label, branch_sort_order FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $storyletId, $gameId, ]); $source = $stmt->fetch(PDO::FETCH_ASSOC); if (!$source) { throw new RuntimeException( 'The Storylet to duplicate could not be found.' ); } $sourceName = trim( (string)( $source['name'] ?? 'Storylet' ) ); if ($sourceName === '') { $sourceName = 'Storylet'; } $baseName = preg_replace( '/\s+-\s+COPY(?:\s+\d+)?$/i', '', $sourceName ); if (!is_string($baseName) || trim($baseName) === '') { $baseName = $sourceName; } $baseName = trim($baseName); /* Leave room for " - COPY 9999" within the 200-character Name. */ $baseName = function_exists('mb_substr') ? mb_substr($baseName, 0, 185, 'UTF-8') : substr($baseName, 0, 185); $copyName = $baseName . ' - COPY'; $copyNumber = 2; $nameCheck = $pdo->prepare( " SELECT 1 FROM wf_storylets WHERE game_id = ? AND name = ? LIMIT 1 " ); while (true) { $nameCheck->execute([ $gameId, $copyName, ]); if (!$nameCheck->fetchColumn()) { break; } $copyName = $baseName . ' - COPY ' . $copyNumber; $copyNumber++; if ($copyNumber > 9999) { throw new RuntimeException( 'Could not generate a unique Storylet copy name.' ); } } $sourceKey = trim( (string)( $source['storylet_key'] ?? '' ) ); if ($sourceKey === '') { $sourceKey = 'storylet-' . $storyletId; } $copyKey = substr( $sourceKey, 0, 80 ) . '-copy-' . bin2hex( random_bytes(6) ); $copyUuid = bin2hex( random_bytes(16) ); $parentId = (int)( $source['parent_storylet_id'] ?? 0 ); $branchSortOrder = (int)( $source['branch_sort_order'] ?? 0 ); $choiceSortOrder = (int)( $source['choice_sort_order'] ?? 0 ); $branchLabel = (string)( $source['branch_label'] ?? '' ); $choiceLabel = (string)( $source['choice_label'] ?? '' ); if ($parentId > 0) { $orderStmt = $pdo->prepare( " SELECT COALESCE(MAX(branch_sort_order), 0) FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? " ); $orderStmt->execute([ $gameId, $parentId, ]); $branchSortOrder = (int)$orderStmt->fetchColumn() + 10; $choiceSortOrder = $branchSortOrder; if (trim($branchLabel) !== '') { $branchLabel = rtrim($branchLabel) . ' - COPY'; } if (trim($choiceLabel) !== '') { $choiceLabel = rtrim($choiceLabel) . ' - COPY'; } } $insert = $pdo->prepare( " INSERT INTO wf_storylets ( uuid, game_id, name, storylet_key, description, conditions, effects, max_occurrences, trigger_type, status, config, parent_storylet_id, choice_label, choice_sort_order, branch_label, branch_sort_order ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?) " ); $insert->execute([ $copyUuid, $gameId, $copyName, $copyKey, $source['description'] ?? null, $source['conditions'] ?? null, $source['effects'] ?? null, $source['max_occurrences'] ?? null, $source['trigger_type'] ?? 'manual', $source['config'] ?? null, $parentId > 0 ? $parentId : null, $choiceLabel !== '' ? $choiceLabel : null, $choiceSortOrder, $branchLabel, $branchSortOrder, ]); $copyId = (int)$pdo->lastInsertId(); if ($copyId <= 0) { throw new RuntimeException( 'The Storylet copy could not be created.' ); } /* Reuse the same game-level media assets; do not duplicate files. */ $media = $pdo->prepare( " INSERT INTO wf_media_links ( media_id, object_type, object_id, slot ) SELECT media_id, object_type, ?, slot FROM wf_media_links WHERE object_type = 'wf_storylets' AND object_id = ? " ); $media->execute([ $copyId, $storyletId, ]); $pdo->commit(); return [ 'duplicated' => true, 'id' => $copyId, 'error' => '', ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } return [ 'duplicated' => false, 'id' => 0, 'error' => $exception->getMessage(), ]; } } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_LINEAR_TRAVEL_SAVE_V72 */ $regionTravel = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionTravelResult = buildRegionTravelFromPost( $record, isset($post['travel']) && is_array($post['travel']) ? $post['travel'] : [] ); $regionTravel = $regionTravelResult['travel'] ?? null; if ( ($regionTravelResult['error'] ?? '') !== '' ) { $error = (string)$regionTravelResult['error']; } } /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ /* WAYFARER_STORYLET_BINDINGS_V79 */ $storyletBindings = []; $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $bindingResult = gameplayBuildStoryletBindingsFromPost( isset($post['bindings']) && is_array($post['bindings']) ? array_values($post['bindings']) : [] ); $storyletBindings = $bindingResult['bindings']; if ($bindingResult['error'] !== '') { $error = $bindingResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [], $storyletBindings, $postedId ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_LINEAR_TRAVEL_SAVE_V72 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTravel !== null) { $configToSave['travel'] = $regionTravel; } else { unset( $configToSave['travel'] ); } } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { /* WAYFARER_STORYLET_BINDINGS_V79 */ if ($storyletBindings) { $configToSave['bindings'] = array_values($storyletBindings); } else { unset($configToSave['bindings']); } if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: _wayfarer_backups/v88-20260929-205950/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* |-------------------------------------------------------------------------- | Party-member Trait catalog | WAYFARER_PARTY_TARGETS_V85 |-------------------------------------------------------------------------- | | Player Traits and authored Character Entity Traits may represent the same | logical per-person property (for example, Health). Party-level authoring | addresses that property by normalized name so one Effect can operate across | the current party without exposing runtime player-slot numbers. | */ function gameplayPartyTraitNormalizeName( string $value ): string { $value = trim( preg_replace( '/\\s+/u', ' ', $value ) ?? $value ); if (function_exists('mb_strtolower')) { return mb_strtolower( $value, 'UTF-8' ); } return strtolower($value); } function gameplayPartyTraitTargetId( string $traitName ): string { return 'party_name_' . bin2hex( gameplayPartyTraitNormalizeName( $traitName ) ); } function gameplayPartyTraitCatalog( PDO $pdo, int $gameId, ?array $playerTraitsOverride = null ): array { if ($gameId <= 0) { return []; } $catalog = []; $append = static function ( array &$catalog, array $trait ): void { if ( !is_array($trait) || !empty($trait['system']) || ( array_key_exists('active', $trait) && !$trait['active'] ) ) { return; } $name = trim( (string)( $trait['name'] ?? '' ) ); if ($name === '') { return; } $type = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); if ($type === 'binary') { $type = 'boolean'; } if ( !in_array( $type, ['number', 'boolean'], true ) ) { return; } $normalized = gameplayPartyTraitNormalizeName( $name ); if ($normalized === '') { return; } if (!isset($catalog[$normalized])) { $catalog[$normalized] = [ 'name' => $name, 'data_type' => $type, 'conflict' => false, ]; return; } if ( (string)$catalog[$normalized]['data_type'] !== $type ) { $catalog[$normalized]['conflict'] = true; } }; if ($playerTraitsOverride === null) { $stmt = $pdo->prepare( 'SELECT config FROM wf_games WHERE id = ? LIMIT 1' ); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); $config = $raw === false ? [] : gameplayRuntimeStateDecodeConfig( $raw ); $playerTraits = isset($config['player_traits']) && is_array($config['player_traits']) ? $config['player_traits'] : []; } else { $playerTraits = $playerTraitsOverride; } foreach ($playerTraits as $trait) { if (is_array($trait)) { $append( $catalog, $trait ); } } $stmt = $pdo->prepare( " SELECT config FROM wf_entities WHERE game_id = ? AND status = 'active' AND LOWER(entity_type) = 'character' ORDER BY id " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (is_array($trait)) { $append( $catalog, $trait ); } } } foreach ($catalog as $normalized => $definition) { if (!empty($definition['conflict'])) { unset($catalog[$normalized]); } } uasort( $catalog, static function ( array $a, array $b ): int { return strcasecmp( (string)$a['name'], (string)$b['name'] ); } ); return $catalog; } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } /* |-------------------------------------------------------------------------- | Status definitions / target metadata | WAYFARER_STATUSES_AUTHORING_V87 |-------------------------------------------------------------------------- */ function gameplayStatusDefinitions( PDO $pdo, int $gameId, bool $activeOnly = true ): array { if ($gameId <= 0) { return []; } $sql = " SELECT id, name, description, config, status FROM wf_statuses WHERE game_id = ? "; if ($activeOnly) { $sql .= " AND status = 'active'"; } $sql .= " ORDER BY name, id"; $stmt = $pdo->prepare($sql); $stmt->execute([$gameId]); $definitions = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $showRaw = $config['show_to_player'] ?? '1'; $showToPlayer = is_bool($showRaw) ? $showRaw : in_array( strtolower(trim((string)$showRaw)), ['1', 'true', 'yes', 'on'], true ); $definitions[(string)$id] = [ 'id' => $id, 'name' => trim((string)($row['name'] ?? ('Status ' . $id))), 'description' => trim((string)($row['description'] ?? '')), 'status' => strtolower(trim((string)($row['status'] ?? 'draft'))), 'show_to_player' => $showToPlayer, ]; } return $definitions; } function gameplayStatusTargetDefinition( string $scope, int $statusId, string $statusName, array $hierarchy, array $extra = [] ): array { return array_merge( [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'status', 'target_id' => (string)$statusId, 'trait_type' => 'boolean', 'data_type' => 'boolean', 'semantic_type' => 'status', 'helper' => 'Persistent Status: Active / Inactive', 'condition_operators' => [ 'equals', 'not_equals', ], 'effect_operations' => [ 'set', ], 'supports_random' => false, 'value_options' => [ '0' => 'Inactive', '1' => 'Active', ], 'group' => 'Statuses', 'label' => $statusName, 'hierarchy' => $hierarchy, ], $extra ); } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } /* WAYFARER_PARTY_TARGETS_V85 */ foreach ( gameplayPartyTraitCatalog( $pdo, $gameId, $playerTraits ) as $partyTrait ) { $partyName = (string)( $partyTrait['name'] ?? '' ); $partyDataType = (string)( $partyTrait['data_type'] ?? 'text' ); if ( $partyName === '' || !in_array( $partyDataType, ['number', 'boolean'], true ) ) { continue; } $partyTargetId = gameplayPartyTraitTargetId( $partyName ); $partyKey = 'party:trait:' . $partyTargetId; $targets[$partyKey] = [ 'scope' => 'party', 'target_scope' => 'party', 'target_type' => 'trait', 'target_id' => $partyTargetId, 'trait_type' => $partyDataType, 'data_type' => $partyDataType, 'semantic_type' => 'party_all_trait', 'helper' => 'Every current party member that exposes this Trait', 'condition_operators' => $partyDataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ], 'effect_operations' => $partyDataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ], 'supports_random' => $partyDataType === 'number', /* * A Party Condition needs an explicit Any/All quantifier model. * Until that exists, this aggregate target is Effect-only. */ 'condition_hidden' => true, 'group' => 'Party', 'label' => 'All Party Members → ' . $partyName, 'hierarchy' => [ [ 'key' => 'party', 'label' => 'Party', ], [ 'key' => 'all_party_members', 'label' => 'All Party Members', ], [ 'key' => $partyTargetId, 'label' => $partyName, ], ], ]; if ($partyDataType === 'boolean') { $targets[$partyKey]['value_options'] = [ '0' => 'False', '1' => 'True', ]; } } /* WAYFARER_STATUS_TARGETS_V87 */ $statusDefinitions = gameplayStatusDefinitions( $pdo, $gameId, true ); foreach ($statusDefinitions as $statusDefinition) { $statusId = (int)($statusDefinition['id'] ?? 0); $statusName = trim((string)($statusDefinition['name'] ?? '')); if ($statusId <= 0 || $statusName === '') { continue; } $partyStatusKey = 'party:status:' . $statusId; $targets[$partyStatusKey] = gameplayStatusTargetDefinition( 'party', $statusId, $statusName, [ [ 'key' => 'party', 'label' => 'Party', ], [ 'key' => 'all_party_members', 'label' => 'All Party Members', ], [ 'key' => 'status:' . $statusId, 'label' => 'Status — ' . $statusName, ], ], [ /* Party Status Conditions need explicit Any/All semantics. */ 'condition_hidden' => true, 'group' => 'Party', 'label' => 'All Party Members → Status — ' . $statusName, ] ); for ($slot = 0; $slot < $playerSlots; $slot++) { $playerStatusKey = 'player:' . $slot . ':status:' . $statusId; $targets[$playerStatusKey] = gameplayStatusTargetDefinition( 'player:' . $slot, $statusId, $statusName, [ [ 'key' => 'player', 'label' => 'Player Character', ], [ 'key' => 'player:' . $slot, 'label' => 'Player ' . ($slot + 1), ], [ 'key' => 'status:' . $statusId, 'label' => 'Status — ' . $statusName, ], ], [ /* Player slots remain a runtime compatibility address. */ 'condition_hidden' => true, 'effect_hidden' => true, ] ); } } /* * Numbered Player slots remain runtime-compatible for existing authored * data, but they are implementation slots rather than useful new targets. * New authoring uses Storylet Binding -> Random Party Member or * Party -> All Party Members instead. */ for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); $playerKey = 'player:' . $slot . ':trait:' . $traitId; if ( $traitId !== '' && isset($targets[$playerKey]) ) { $targets[$playerKey]['condition_hidden'] = true; $targets[$playerKey]['effect_hidden'] = true; } } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } if ($entityType === 'character') { foreach ($statusDefinitions as $statusDefinition) { $statusId = (int)($statusDefinition['id'] ?? 0); $statusName = trim((string)($statusDefinition['name'] ?? '')); if ($statusId <= 0 || $statusName === '') { continue; } $statusKey = 'entity:' . $id . ':status:' . $statusId; $targets[$statusKey] = gameplayStatusTargetDefinition( 'entity:' . $id, $statusId, $statusName, [ [ 'key' => 'character', 'label' => 'Character', ], [ 'key' => 'entity:' . $id, 'label' => $name, ], [ 'key' => 'status:' . $statusId, 'label' => 'Status — ' . $statusName, ], ], [ 'group' => 'Characters', 'label' => $name . ' → Status — ' . $statusName, ] ); } } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ /* |-------------------------------------------------------------------------- | Navigation Condition Targets |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITION_TARGETS_V75 | WAYFARER_LIVE_LOCATION_ANCHORS_V76 | | Numeric Position Conditions are Region-qualified. | | New Location Conditions reference wf_locations.id as target_id. The | comparison itself is against zero because runtime resolves the target to: | | current position - current anchor position | | That makes a Location a live spatial anchor: | | = 0 => At | != 0 => Not at | < 0 => Before | <= 0 => At or before | > 0 => After | >= 0 => At or after | | The V75 snapshot target navigation:location:location remains hidden for | backward compatibility only. | */ function gameplayConditionNavigationPositionToken( $value ): string { if (!is_numeric($value)) { return ''; } $number = (float)$value; if (is_finite($number) && floor($number) == $number) { return (string)((int)$number); } $token = rtrim( rtrim( sprintf('%.12F', $number), '0' ), '.' ); return $token === '-0' ? '0' : $token; } function gameplayNavigationConditionTargets( PDO $pdo, int $gameId ): array { $targets = []; /* * Compatibility only. Existing saved unqualified Position Conditions * still render and save, but cannot be newly authored. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position_legacy', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'group' => 'Navigation', 'label' => 'Position (legacy unqualified)', 'condition_hidden' => true, ]; /* * Compatibility only. V75 stored Region + Position in the value itself. * New V76 Conditions never use this target. */ $targets['navigation:location:location'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => 'location', 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'navigation_location_v75_snapshot', 'helper' => 'V75 snapshot', 'condition_operators' => [ 'equals', 'not_equals', ], 'group' => 'Navigation', 'label' => 'Location (legacy V75 snapshot)', 'condition_hidden' => true, ]; if ($gameId <= 0) { return $targets; } /* * Fixed numeric Position, explicitly qualified to one linear Region. */ $regionStmt = $pdo->prepare( " SELECT id, name, navigation_type FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $regionStmt->execute([ $gameId ]); foreach ( $regionStmt->fetchAll(PDO::FETCH_ASSOC) as $region ) { $regionId = (int)($region['id'] ?? 0); if ($regionId <= 0) { continue; } if ( strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) !== 'linear' ) { continue; } $regionName = trim( (string)( $region['name'] ?? ('Region ' . $regionId) ) ); $scope = 'navigation:' . $regionId; $key = $scope . ':position:position'; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_region_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'group' => 'Navigation — Positions', 'label' => $regionName, ]; } /* * Named Locations are LIVE references. Each Location is its own target, * identified by wf_locations.id. Position is shown only as authoring help; * it is resolved again from current Location config at runtime. */ $locationStmt = $pdo->prepare( " SELECT l.id, l.name, l.config, l.region_id, r.name AS region_name, r.navigation_type FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $locationStmt->execute([ $gameId ]); foreach ( $locationStmt->fetchAll(PDO::FETCH_ASSOC) as $location ) { if ( strtolower( trim( (string)( $location['navigation_type'] ?? '' ) ) ) !== 'linear' ) { continue; } $locationId = (int)( $location['id'] ?? 0 ); if ($locationId <= 0) { continue; } $config = gameplayRuntimeStateDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $position = gameplayConditionNavigationPositionToken( $config['position'] ); if ($position === '') { continue; } $locationName = trim( (string)( $location['name'] ?? ('Location ' . $locationId) ) ); $regionName = trim( (string)( $location['region_name'] ?? 'Region' ) ); $key = 'navigation:location:' . $locationId; $targets[$key] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => (string)$locationId, 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_location_anchor', 'helper' => $regionName . ' · Position ' . $position, 'condition_operators' => [ 'equals', 'not_equals', 'less_than', 'less_than_or_equal', 'greater_than', 'greater_than_or_equal', ], 'condition_operator_labels' => [ 'equals' => 'At', 'not_equals' => 'Not at', 'less_than' => 'Before', 'less_than_or_equal' => 'At or before', 'greater_than' => 'After', 'greater_than_or_equal' => 'At or after', ], /* * The runtime value is a signed delta from this anchor, so every * comparison is against zero. Keep that implementation detail out * of the authoring UI while still posting a normal Condition value. */ 'value_options' => [ '0' => 'Anchor', ], 'condition_hide_value' => true, 'group' => 'Navigation — Locations', 'label' => $locationName, ]; } return $targets; } /* * Allows Condition POST validation to recognize V76 navigation-only target * shapes even when a caller supplied the older universal Effect catalog. */ /* |-------------------------------------------------------------------------- | Storylet Navigation Effect targets | WAYFARER_STORYLET_ROOT_CONNECTIONS_V82 |-------------------------------------------------------------------------- | | Explicit Storylet navigation is an Effect-only target. A Storylet may | continue directly to another root Storylet after its other Effects resolve. | Child Storylets remain structural Branches and are deliberately not exposed | as destinations here. */ function gameplayStoryletNavigationEffectTargets( PDO $pdo, int $gameId, int $currentStoryletId = 0 ): array { $targets = []; if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, status FROM wf_storylets WHERE game_id = ? AND ( parent_storylet_id IS NULL OR parent_storylet_id = 0 ) ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $storylet ) { $storyletId = (int)( $storylet['id'] ?? 0 ); if ( $storyletId <= 0 || ( $currentStoryletId > 0 && $storyletId === $currentStoryletId ) ) { continue; } $name = trim( (string)( $storylet['name'] ?? '' ) ); if ($name === '') { $name = 'Storylet ' . $storyletId; } $status = strtolower( trim( (string)( $storylet['status'] ?? '' ) ) ); $helper = $status !== '' ? ucfirst($status) . ' root Storylet' : 'Root Storylet'; $key = 'storylet:transition:' . $storyletId; $targets[$key] = [ 'scope' => 'storylet', 'target_scope' => 'storylet', 'target_type' => 'transition', 'target_id' => (string)$storyletId, 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'storylet_transition', 'helper' => $helper, 'effect_operations' => [ 'continue', ], 'supports_random' => false, 'group' => 'Storylet Navigation', 'label' => $name . ' → Continue To', 'hierarchy' => [ [ 'key' => 'storylet', 'label' => 'Storylet', ], [ 'key' => 'storylet:' . $storyletId, 'label' => $name, ], [ 'key' => 'continue_to', 'label' => 'Continue To', ], ], ]; } return $targets; } /* |-------------------------------------------------------------------------- | Hierarchical target-ready Time Conditions | WAYFARER_HIERARCHICAL_TARGETS_V80 |-------------------------------------------------------------------------- | | The hierarchy UI consumes a generic target catalog. New systems can opt in | simply by supplying a three-level hierarchy array: | | Type -> Specific Target -> Property | | Current Game Time is Condition-only. Effects do not modify the clock here. | Calendar / Seasons can later register additional Time properties without | changing the picker. */ function gameplayTimeConditionTargets( PDO $pdo, int $gameId ): array { $targets = []; if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ($raw === false) { return $targets; } $config = gameplayRuntimeStateDecodeConfig( $raw ); $time = isset($config['time']) && is_array($config['time']) ? $config['time'] : []; $mode = strtolower( trim( (string)( $time['mode'] ?? 'none' ) ) ); if ( $mode !== 'days' && $mode !== 'hours' ) { return $targets; } $base = [ 'scope' => 'time', 'target_scope' => 'time', 'target_type' => 'clock', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'game_time', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'group' => 'Time', ]; $targets['time:clock:day'] = array_merge( $base, [ 'target_id' => 'day', 'label' => 'Game Clock → Day', 'hierarchy' => [ [ 'key' => 'time', 'label' => 'Time', ], [ 'key' => 'game_clock', 'label' => 'Game Clock', ], [ 'key' => 'day', 'label' => 'Day', ], ], ] ); if ($mode === 'hours') { $targets['time:clock:hour'] = array_merge( $base, [ 'target_id' => 'hour', 'label' => 'Game Clock → Hour', 'hierarchy' => [ [ 'key' => 'time', 'label' => 'Time', ], [ 'key' => 'game_clock', 'label' => 'Game Clock', ], [ 'key' => 'hour', 'label' => 'Hour', ], ], ] ); } return $targets; } function gameplaySyntheticTimeConditionTarget( string $target ): ?array { if ($target === 'time:clock:day') { return [ 'scope' => 'time', 'target_scope' => 'time', 'target_type' => 'clock', 'target_id' => 'day', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'game_time', 'condition_operators' => wayfarerConditionOperators(), ]; } if ($target === 'time:clock:hour') { return [ 'scope' => 'time', 'target_scope' => 'time', 'target_type' => 'clock', 'target_id' => 'hour', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'game_time', 'condition_operators' => wayfarerConditionOperators(), ]; } return null; } function gameplaySyntheticNavigationConditionTarget( string $target ): ?array { if ( preg_match( '/^navigation:([1-9][0-9]*):position:position$/', $target, $match ) === 1 ) { $scope = 'navigation:' . (int)$match[1]; return [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_region_position', 'condition_operators' => wayfarerConditionOperators(), ]; } if ( preg_match( '/^navigation:location:([1-9][0-9]*)$/', $target, $match ) === 1 ) { return [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => (string)((int)$match[1]), 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_location_anchor', 'condition_operators' => [ 'equals', 'not_equals', 'less_than', 'less_than_or_equal', 'greater_than', 'greater_than_or_equal', ], ]; } /* V75 snapshot compatibility. */ if ($target === 'navigation:location:location') { return [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => 'location', 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'navigation_location_v75_snapshot', 'condition_operators' => [ 'equals', 'not_equals', ], ]; } return null; } function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return array_replace( gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ), gameplayNavigationConditionTargets( $pdo, $gameId ), gameplayTimeConditionTargets( $pdo, $gameId ) ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; /* * WAYFARER_LOCATION_CONDITION_TARGETS_V75 * * Older callers may still pass the universal Effect target catalog. * Region-qualified Position and Location targets are Condition-only, * so synthesize their definitions here when needed. */ if ( $target !== '' && !isset($allowedTargets[$target]) ) { $syntheticTarget = gameplaySyntheticNavigationConditionTarget( $target ); if ( $syntheticTarget === null && function_exists( 'gameplaySyntheticTimeConditionTarget' ) ) { $syntheticTarget = gameplaySyntheticTimeConditionTarget( $target ); } if ($syntheticTarget !== null) { $allowedTargets[$target] = $syntheticTarget; } } if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: _wayfarer_backups/v88-20260929-205950/play/includes/action-screen.php ================================================================================ 0) { $actionRegionStmt = $pdo->prepare( " SELECT id, name, navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $actionRegionStmt->execute([ $actionRegionId, (int)$gameId, ]); $actionRegion = $actionRegionStmt->fetch( PDO::FETCH_ASSOC ); if ($actionRegion) { $actionRegionNavigationType = (string)( $actionRegion['navigation_type'] ?? '' ); $actionContextName = trim( (string)( $actionRegion['name'] ?? '' ) ) ?: 'Actions'; } } $actionLocation = wayfarerRuntimeFindCurrentLocation( $pdo, (int)$gameId, $state ); if ($actionLocation) { $locationName = trim( (string)( $actionLocation['name'] ?? '' ) ); if ($locationName !== '') { $actionContextName = $locationName; } } $actionCanTravel = strtolower( trim( $actionRegionNavigationType ) ) === 'linear'; /* WAYFARER_STORYLET_ACTION_SCREEN_V59 */ $actionStorylets = wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$gameId, $state ); /* * WAYFARER_ENTITY_PRESENTATION_V49 * * The V48 card switcher remains generic. V49 supplies the Inventory / Status * view models from canonical runtime Entity state. */ $actionEntityPresentation = wayfarerRuntimeBuildEntityPresentation( $pdo, (int)$gameId, $state, is_array($gameConfig ?? null) ? $gameConfig : [] ); $actionInventory = $actionEntityPresentation['inventory'] ?? []; $actionParty = $actionEntityPresentation['party'] ?? []; /* WAYFARER_PLAYER_TRAITS_STATUS_V52 */ $actionPlayerPresentation = wayfarerRuntimeBuildPlayerPresentation( is_array($gameConfig ?? null) ? $gameConfig : [], $state ); $actionPlayers = $actionPlayerPresentation['players'] ?? []; $actionGameStatusTraits = $actionPlayerPresentation['game_status_traits'] ?? []; /* WAYFARER_STATUSES_PRESENTATION_V87 */ foreach ($actionPlayers as &$actionStatusPlayer) { $slot = (int)( $actionStatusPlayer['slot'] ?? 0 ); $actionStatusPlayer['statuses'] = wayfarerRuntimeVisibleStatusesForScope( $pdo, (int)$gameId, $state, 'player:' . $slot ); } unset($actionStatusPlayer); foreach ($actionParty as &$actionStatusCharacter) { $entityId = (int)( $actionStatusCharacter['id'] ?? 0 ); $actionStatusCharacter['statuses'] = $entityId > 0 ? wayfarerRuntimeVisibleStatusesForScope( $pdo, (int)$gameId, $state, 'entity:' . $entityId ) : []; } unset($actionStatusCharacter); /* * WAYFARER_STATUS_TRAIT_DISPLAY_V86 * * Status Traits may render as a Number, a Bar, or Both. Runtime presentation * metadata already falls back to Number when a bar cannot be calculated. */ $renderStatusTrait = static function ( array $trait ): void { $name = trim( (string)( $trait['name'] ?? '' ) ); $value = (string)( $trait['value'] ?? '' ); $mode = strtolower( trim( (string)( $trait['display_mode'] ?? 'number' ) ) ); if ( !in_array( $mode, ['number', 'bar', 'both'], true ) ) { $mode = 'number'; } $showBar = !empty( $trait['bar_available'] ) && is_numeric( $trait['bar_percent'] ?? null ) && ( $mode === 'bar' || $mode === 'both' ); if (!$showBar && $mode !== 'number') { $mode = 'number'; } $showValue = $mode === 'number' || $mode === 'both'; $percent = $showBar ? max( 0, min( 100, (float)$trait['bar_percent'] ) ) : 0; $minimum = $trait['bar_min'] ?? 0; $maximum = $trait['bar_max'] ?? 100; $current = $trait['bar_value'] ?? 0; ?>

>

What would you like to do?

No actions are currently available.

================================================================================ FILE: _wayfarer_backups/v88-20260929-205950/play/includes/runtime.php ================================================================================ $mode, 'starting_day' => max(1, (int)($raw['starting_day'] ?? 1)), 'ticks_per_day' => max(1, (int)($raw['ticks_per_day'] ?? 1)), 'starting_hour' => max(0, (int)($raw['starting_hour'] ?? 8)), 'ticks_per_hour' => max(1, (int)($raw['ticks_per_hour'] ?? 1)), 'hours_per_day' => max(1, (int)($raw['hours_per_day'] ?? 24)), ]; if ($config['starting_hour'] >= $config['hours_per_day']) { $config['starting_hour'] = $config['hours_per_day'] - 1; } return $config; } function wayfarerRuntimeInitializeClockFromConfig( array $gameConfig, array &$runtimeState ): void { if (!isset($runtimeState['runtime']) || !is_array($runtimeState['runtime'])) { $runtimeState['runtime'] = []; } $config = wayfarerRuntimeTimeConfig($gameConfig); if ($config['mode'] === 'none') { unset($runtimeState['runtime']['clock']); return; } $clock = [ 'mode' => $config['mode'], 'day' => $config['starting_day'], ]; if ($config['mode'] === 'days') { $clock['tick_in_day'] = 0; } else { $clock['hour'] = $config['starting_hour']; $clock['tick_in_hour'] = 0; } $runtimeState['runtime']['clock'] = $clock; } function wayfarerRuntimeLoadGameConfig(PDO $pdo, int $gameId): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare('SELECT config FROM wf_games WHERE id = ? LIMIT 1'); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); return wayfarerRuntimeDecodeConfig($raw === false ? null : $raw); } function wayfarerRuntimeEnsureClock( PDO $pdo, int $gameId, array &$runtimeState ): array { $gameConfig = wayfarerRuntimeLoadGameConfig($pdo, $gameId); $timeConfig = wayfarerRuntimeTimeConfig($gameConfig); $clock = $runtimeState['runtime']['clock'] ?? null; if ($timeConfig['mode'] === 'none') { unset($runtimeState['runtime']['clock']); return $timeConfig; } if (!is_array($clock) || (string)($clock['mode'] ?? '') !== $timeConfig['mode']) { wayfarerRuntimeInitializeClockFromConfig($gameConfig, $runtimeState); } return $timeConfig; } function wayfarerRuntimeAdvanceClock( PDO $pdo, int $gameId, array &$runtimeState ): void { $config = wayfarerRuntimeEnsureClock($pdo, $gameId, $runtimeState); if ($config['mode'] === 'none') { return; } $clock =& $runtimeState['runtime']['clock']; $clock['day'] = max(1, (int)($clock['day'] ?? $config['starting_day'])); if ($config['mode'] === 'days') { $clock['tick_in_day'] = max(0, (int)($clock['tick_in_day'] ?? 0)) + 1; if ($clock['tick_in_day'] >= $config['ticks_per_day']) { $clock['day']++; $clock['tick_in_day'] = 0; } return; } $clock['hour'] = max(0, (int)($clock['hour'] ?? $config['starting_hour'])); $clock['tick_in_hour'] = max(0, (int)($clock['tick_in_hour'] ?? 0)) + 1; if ($clock['tick_in_hour'] >= $config['ticks_per_hour']) { $clock['tick_in_hour'] = 0; $clock['hour']++; if ($clock['hour'] >= $config['hours_per_day']) { $clock['hour'] = 0; $clock['day']++; } } } function wayfarerRuntimeClockLabel(array $runtimeState, array $timeConfig): string { if ($timeConfig['mode'] === 'none') { return ''; } $clock = $runtimeState['runtime']['clock'] ?? []; $day = max(1, (int)($clock['day'] ?? $timeConfig['starting_day'])); if ($timeConfig['mode'] === 'days') { return 'Day ' . $day; } $hour = max(0, (int)($clock['hour'] ?? $timeConfig['starting_hour'])); if ($timeConfig['hours_per_day'] === 24) { $displayHour = $hour % 12; if ($displayHour === 0) { $displayHour = 12; } $suffix = $hour < 12 ? 'AM' : 'PM'; return 'Day ' . $day . ' · ' . $displayHour . ':00 ' . $suffix; } return 'Day ' . $day . ' · Hour ' . $hour; } /* * WAYFARER_BIDIRECTIONAL_TRAVEL_V71 * * Runtime direction is journey state, not Region authoring state. +1 follows * the Region's authored forward Tick Effect; -1 reverses it. Keeping the * authored Add/Subtract sign as the base preserves existing Regions while * allowing the same line to be traversed in either direction. */ function wayfarerRuntimeNavigationDirection(array $runtimeState): int { $direction = (int)( $runtimeState['navigation']['direction'] ?? 1 ); return $direction < 0 ? -1 : 1; } /* * WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 * * Returns canonical dedicated travel configuration, or null for Regions that * still use the legacy Position Tick Effect movement path. */ function wayfarerRuntimeDedicatedLinearTravel( array $regionConfig ): ?array { $travel = isset($regionConfig['travel']) && is_array($regionConfig['travel']) ? $regionConfig['travel'] : null; if ( $travel === null || empty($travel['enabled']) || !isset($travel['base_distance']) || !is_numeric($travel['base_distance']) || (float)$travel['base_distance'] <= 0 ) { return null; } return [ 'base_distance' => (float)$travel['base_distance'], 'allow_turnaround' => !empty( $travel['allow_turnaround'] ), ]; } /* WAYFARER_GAME_TRAVEL_MODIFIERS_MOVEMENT_V73 */ function wayfarerRuntimeApplyDedicatedLinearTravelMovement( array &$runtimeState, array $travel, float $modifierAdjustment = 0.0, array $matchedModifiers = [] ): array { if ( !isset($runtimeState['navigation']) || !is_array($runtimeState['navigation']) ) { $runtimeState['navigation'] = []; } $before = $runtimeState['navigation']['position'] ?? 0; if (!is_numeric($before)) { return [ 'scope' => 'navigation', 'type' => 'position', 'target_id' => 'position', 'applied' => false, 'error' => 'Navigation position must be numeric.', ]; } $baseDistance = max( 0.0, (float)( $travel['base_distance'] ?? 0 ) ); /* * Modifiers may slow travel to zero, but never reverse the player's * chosen direction. Direction is applied only after effective distance is * clamped to a non-negative value. */ $distance = max( 0.0, $baseDistance + $modifierAdjustment ); $direction = wayfarerRuntimeNavigationDirection( $runtimeState ); $after = max( 0.0, (float)$before + ($distance * $direction) ); $after = wayfarerNormalizeRuntimeNumber( $after ); $runtimeState['navigation']['position'] = $after; return [ 'scope' => 'navigation', 'type' => 'position', 'target_id' => 'position', 'operation' => 'travel', 'base_distance' => wayfarerNormalizeRuntimeNumber( $baseDistance ), 'modifier_adjustment' => wayfarerNormalizeRuntimeNumber( $modifierAdjustment ), 'matched_modifiers' => array_values( $matchedModifiers ), 'amount' => wayfarerNormalizeRuntimeNumber( $distance ), 'direction' => $direction, 'before' => wayfarerNormalizeRuntimeNumber( (float)$before ), 'after' => $after, 'applied' => true, 'error' => '', ]; } function wayfarerRuntimeLinearTravelDirection( array $regionConfig, array $runtimeState ): int { $runtimeDirection = wayfarerRuntimeNavigationDirection( $runtimeState ); /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ if ( wayfarerRuntimeDedicatedLinearTravel( $regionConfig ) !== null ) { return $runtimeDirection; } $effects = isset($regionConfig['tick_effects']) && is_array($regionConfig['tick_effects']) ? $regionConfig['tick_effects'] : []; foreach ($effects as $effect) { if (!is_array($effect)) { continue; } if ( (string)($effect['target_scope'] ?? '') !== 'navigation' || (string)($effect['target_type'] ?? '') !== 'position' || (string)($effect['target_id'] ?? '') !== 'position' ) { continue; } $branches = wayfarerRuntimeTickEffectBranches($effect); foreach ($branches as $branch) { $operation = strtolower(trim((string)($branch['operation'] ?? ''))); if ($operation === 'subtract') { return -1 * $runtimeDirection; } if ($operation === 'add') { return 1 * $runtimeDirection; } } } return $runtimeDirection; } function wayfarerRuntimeBuildTravelStatus( PDO $pdo, int $gameId, array &$runtimeState ): array { $gameConfig = wayfarerRuntimeLoadGameConfig($pdo, $gameId); $timeConfig = wayfarerRuntimeEnsureClock($pdo, $gameId, $runtimeState); $result = [ 'time_label' => wayfarerRuntimeClockLabel($runtimeState, $timeConfig), 'region_name' => '', 'distance_label' => 'units', 'next_location_id' => 0, 'next_location_name' => '', 'distance_to_next' => null, 'next_label' => '', /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ 'dedicated_travel' => false, 'base_distance_per_tick' => null, 'allow_turnaround' => false, 'can_turn_around' => false, 'direction' => 1, ]; $regionId = (int)($runtimeState['navigation']['region_id'] ?? 0); $position = $runtimeState['navigation']['position'] ?? null; if ($regionId <= 0 || !is_numeric($position)) { return $result; } $stmt = $pdo->prepare( 'SELECT id, name, navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1' ); $stmt->execute([$regionId, $gameId]); $region = $stmt->fetch(PDO::FETCH_ASSOC); if (!$region) { return $result; } $result['region_name'] = trim((string)($region['name'] ?? '')); if (strtolower(trim((string)($region['navigation_type'] ?? ''))) !== 'linear') { return $result; } $regionConfig = wayfarerRuntimeDecodeConfig($region['config'] ?? null); $distanceLabel = trim((string)($regionConfig['distance_label'] ?? 'units')); $result['distance_label'] = $distanceLabel !== '' ? $distanceLabel : 'units'; $direction = wayfarerRuntimeLinearTravelDirection($regionConfig, $runtimeState); $current = (float)$position; /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ $dedicatedTravel = wayfarerRuntimeDedicatedLinearTravel( $regionConfig ); $result['direction'] = $direction; if ($dedicatedTravel !== null) { $result['dedicated_travel'] = true; $result['base_distance_per_tick'] = wayfarerNormalizeRuntimeNumber( (float)$dedicatedTravel[ 'base_distance' ] ); $result['allow_turnaround'] = !empty( $dedicatedTravel[ 'allow_turnaround' ] ); /* * Do not offer a reversal that would immediately point out of the * legal lower boundary. If already traveling toward Start at zero, * turning around toward End remains valid. */ $result['can_turn_around'] = $result['allow_turnaround'] && !( $current <= 0 && $direction > 0 ); } $locationStmt = $pdo->prepare( "SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id" ); $locationStmt->execute([$regionId]); $best = null; $bestDistance = null; foreach ($locationStmt->fetchAll(PDO::FETCH_ASSOC) as $location) { $locationConfig = wayfarerRuntimeDecodeConfig($location['config'] ?? null); if (!isset($locationConfig['position']) || !is_numeric($locationConfig['position'])) { continue; } $locationPosition = (float)$locationConfig['position']; $ahead = $direction < 0 ? $locationPosition < $current : $locationPosition > $current; if (!$ahead) { continue; } $location['_runtime_config'] = $locationConfig; if (!wayfarerRuntimeLocationConditionsPass($pdo, $gameId, $location, $runtimeState)) { continue; } $distance = abs($locationPosition - $current); if ($best === null || $distance < $bestDistance) { $best = $location; $bestDistance = $distance; } } if ($best !== null) { $distance = wayfarerNormalizeRuntimeNumber((float)$bestDistance); $name = trim((string)($best['name'] ?? 'Location')) ?: 'Location'; $result['next_location_id'] = (int)($best['id'] ?? 0); $result['next_location_name'] = $name; $result['distance_to_next'] = $distance; $result['next_label'] = $name . ' — ' . $distance . ' ' . $result['distance_label']; } return $result; } function wayfarerRuntimeActiveTraitDefinitions( array $config ): array { $traits = $config['traits'] ?? []; if (!is_array($traits)) { return []; } $result = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( array_key_exists( 'active', $trait ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $trait['_runtime_id'] = $traitId; $result[] = $trait; } return $result; } function wayfarerRuntimeInitialTraitValue( array $trait ) { if ( array_key_exists( 'initial', $trait ) ) { return $trait['initial']; } $type = strtolower( trim( (string)( $trait['type'] ?? '' ) ) ); if ( $type === 'number' || $type === 'binary' || $type === 'boolean' ) { return 0; } return null; } function wayfarerRuntimeInitialTraitState( array $config ): array { $state = []; foreach ( wayfarerRuntimeActiveTraitDefinitions( $config ) as $trait ) { $traitId = (string)$trait['_runtime_id']; $state[$traitId] = wayfarerRuntimeInitialTraitValue( $trait ); } return $state; } /* |-------------------------------------------------------------------------- | Persisted state for every Region |-------------------------------------------------------------------------- | WAYFARER_ALL_REGION_STATE_V46 | | Game-wide Conditions may inspect a Region that the player has never visited. | Therefore every authored Region needs persistent Trait state, not only the | current Region. | | This helper is additive/recovery-safe: | - existing runtime values are never overwritten; | - missing Regions or newly-added Traits receive their authored initial; | - stale historical values are not destructively removed here. | */ function wayfarerRuntimeEnsureAllRegionTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['regions']) || !is_array($runtimeState['regions']) ) { $runtimeState['regions'] = []; } $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? ORDER BY id " ); $stmt->execute([ $gameId ]); $regions = $stmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($regions as $region) { $regionId = (int)( $region['id'] ?? 0 ); if ($regionId <= 0) { continue; } $regionKey = (string)$regionId; $config = wayfarerRuntimeDecodeConfig( $region['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset( $runtimeState['regions'][$regionKey] ) || !is_array( $runtimeState['regions'][$regionKey] ) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset( $runtimeState['regions'][$regionKey]['traits'] ) || !is_array( $runtimeState['regions'][$regionKey]['traits'] ) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['regions'][$regionKey]['traits'] ) ) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $initialValue; } } } /* |-------------------------------------------------------------------------- | Persisted state for every Location | WAYFARER_ALL_LOCATION_STATE_V56 |-------------------------------------------------------------------------- | | Locations can own Traits, so universal Conditions/Effects need persistent | per-playthrough Location Trait state just like Regions. | */ function wayfarerRuntimeEnsureAllLocationTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['locations']) || !is_array($runtimeState['locations']) ) { $runtimeState['locations'] = []; } $stmt = $pdo->prepare( " SELECT l.id, l.region_id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $location ) { $locationId = (int)($location['id'] ?? 0); if ($locationId <= 0) { continue; } $locationKey = (string)$locationId; $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset($runtimeState['locations'][$locationKey]) || !is_array($runtimeState['locations'][$locationKey]) ) { $runtimeState['locations'][$locationKey] = []; } /* * WAYFARER_LIVE_LOCATION_ANCHORS_V76 * * These are definition-derived spatial facts, so refresh them every * ensure pass. Moving a Location in Builder therefore changes the * anchor without rewriting authored Conditions. */ $runtimeState['locations'][$locationKey]['region_id'] = (int)( $location['region_id'] ?? 0 ); $runtimeState['locations'][$locationKey]['position'] = isset($config['position']) && is_numeric($config['position']) ? (float)$config['position'] : null; if ( !isset($runtimeState['locations'][$locationKey]['traits']) || !is_array($runtimeState['locations'][$locationKey]['traits']) ) { $runtimeState['locations'][$locationKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['locations'][$locationKey]['traits'] ) ) { continue; } $runtimeState['locations'][$locationKey]['traits'][$traitId] = $initialValue; } } } function wayfarerRuntimeLoadStartingRegion( PDO $pdo, int $gameId, ?array $gameConfig = null ): ?array { if ($gameId <= 0) { return null; } if ($gameConfig === null) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { return null; } $gameConfig = wayfarerRuntimeDecodeConfig( $rawConfig ); } $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT * FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $startingRegionId, $gameId, ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); return $region ?: null; } function wayfarerRuntimeInitializeGame( PDO $pdo, int $gameUserId, array $setupInput = [] ): array { if ($gameUserId <= 0) { throw new RuntimeException( 'A valid game-user ID is required to initialize the game.' ); } try { $pdo->beginTransaction(); /* * Lock the playthrough row so two initialize requests cannot create * competing starting states. */ $stmt = $pdo->prepare( " SELECT gu.id, gu.game_id, gu.user_id, gu.role, gu.state, g.config AS game_config, g.player_slots AS player_slots FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id WHERE gu.id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } /* * Initialization is intentionally one-way. To restart a development * game, clear wf_game_users.state to NULL first. */ if ( $row['state'] !== null && $row['state'] !== '' ) { throw new RuntimeException( 'This game-user already has runtime state.' ); } $gameId = (int)$row['game_id']; $gameConfig = wayfarerRuntimeDecodeConfig( $row['game_config'] ?? null ); $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { throw new RuntimeException( 'This Game does not have a Starting Region configured.' ); } $startingRegion = wayfarerRuntimeLoadStartingRegion( $pdo, $gameId, $gameConfig ); if (!$startingRegion) { throw new RuntimeException( 'The configured Starting Region does not exist for this Game.' ); } $regionConfig = wayfarerRuntimeDecodeConfig( $startingRegion['config'] ?? null ); $gameTraits = wayfarerRuntimeInitialTraitState( $gameConfig ); $regionTraits = wayfarerRuntimeInitialTraitState( $regionConfig ); $navigation = [ 'region_id' => (int)$startingRegion['id'], /* WAYFARER_BIDIRECTIONAL_TRAVEL_V71 */ 'direction' => 1, ]; /* * Linear travel currently begins at coordinate zero. * Other navigation modes will get their own authored start-state * rules when those modes are implemented. */ if ( strtolower( trim( (string)( $startingRegion['navigation_type'] ?? '' ) ) ) === 'linear' ) { $navigation['position'] = 0; } $regionKey = (string)$startingRegion['id']; $state = [ /* * WAYFARER_RUNTIME_SCHEMA_V55 * WAYFARER_BIDIRECTIONAL_TRAVEL_V71 * Version the persisted playthrough contract explicitly. */ 'schema_version' => 2, 'game' => [ 'traits' => $gameTraits, ], 'navigation' => $navigation, 'regions' => [ $regionKey => [ 'traits' => $regionTraits, ], ], 'runtime' => [ 'tick' => 0, ], ]; /* WAYFARER_INITIALIZE_GAME_CLOCK_V61 */ wayfarerRuntimeInitializeClockFromConfig( $gameConfig, $state ); /* * WAYFARER_ALL_REGION_STATE_V46 * * Global conditions can inspect Region state before that Region has * ever been visited. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $state ); /* WAYFARER_INITIALIZE_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_V49 * * Items and Characters receive canonical per-playthrough state before * any starting setup effects are applied. */ wayfarerRuntimeEnsureEntityState( $pdo, $gameId, $state ); /* * WAYFARER_APPLY_STARTING_CHOICES_V36 * * Validate all player-entered setup data before persisting anything. * Then apply the selected options' starting effects to the canonical * initial runtime state. */ $preparedSetup = wayfarerRuntimePrepareStartingSetup( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $setupInput ); /* * WAYFARER_PLAYER_TRAITS_RUNTIME_V52 * * Persist the validated player-character names before creating the * per-player Trait state. Each slot receives independent values. */ $state['setup'] = [ 'player_names' => $preparedSetup['player_names'], 'choices' => $preparedSetup['choices'], ]; wayfarerRuntimeEnsureGameTraitState( $gameConfig, $state ); wayfarerRuntimeEnsurePlayerState( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $state ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $startingEffectReports = []; foreach ( $preparedSetup['effects'] as $startingEffect ) { /* WAYFARER_STARTING_CHOICE_RANDOM_RUNTIME_V58 */ $resolvedStartingValue = wayfarerRuntimeRollEffectValue( $startingEffect ); if (!$resolvedStartingValue['valid']) { throw new RuntimeException( 'Unable to resolve a Starting Choice effect value: ' . $resolvedStartingValue['error'] ); } $concreteStartingEffect = $startingEffect; $concreteStartingEffect['value'] = $resolvedStartingValue['value']; $report = wayfarerApplyRuntimeEffect( $state, $concreteStartingEffect, (int)$startingRegion['id'], $traitDefinitions ); if ( !empty($report['error']) ) { throw new RuntimeException( 'Unable to apply a Starting Choice effect: ' . $report['error'] ); } $report['value_mode'] = $resolvedStartingValue['mode']; $report['rolled_value'] = $resolvedStartingValue['value']; if ($resolvedStartingValue['mode'] === 'random') { $report['random_min'] = $resolvedStartingValue['min']; $report['random_max'] = $resolvedStartingValue['max']; } $startingEffectReports[] = $report; } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * * Starting-effect reports are intentionally not persisted. The effects * themselves have already mutated canonical state. */ /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 * * A game that begins exactly on an active Location should present that * Location before ordinary Action Mode. */ $startingLocation = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $state ); if ($startingLocation) { /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Starting on a Location is a real Location trigger. Apply its * one-time Effects before opening Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, $gameId, $startingLocation, $state ); $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$startingLocation['id'], 'phase' => 'presentation', 'trigger' => 'initial', 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /* WAYFARER_GAME_ENDINGS_INITIAL_V45 */ wayfarerRuntimeEvaluateGameEndings( $pdo, $gameId, $state ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => $gameId, 'starting_region_id' => (int)$startingRegion['id'], 'state' => $state, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Resolve a game-user record for the tick prototype. * * Preferred: * explicit wf_game_users.id * * Temporary development fallback: * first wf_game_users record. * * The fallback exists only because Wayfarer has not yet wired the play * page to authentication / game selection. Once that exists, remove the * fallback and always pass the active game_user_id explicitly. */ function wayfarerRuntimeResolveGameUserId( PDO $pdo, ?int $requestedId = null ): int { if ($requestedId !== null && $requestedId > 0) { $stmt = $pdo->prepare( " SELECT id FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $requestedId ]); $id = $stmt->fetchColumn(); if ($id !== false) { return (int)$id; } throw new RuntimeException( 'The requested game-user record does not exist.' ); } $id = $pdo ->query( " SELECT id FROM wf_game_users ORDER BY id LIMIT 1 " ) ->fetchColumn(); if ($id === false) { throw new RuntimeException( 'No wf_game_users record exists.' ); } return (int)$id; } /** * Load one game-user runtime state. */ function wayfarerRuntimeLoad( PDO $pdo, int $gameUserId ): array { $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'state' => $state, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /** * Advance exactly one tick. * * This is intentionally the entire v14 game engine: * * state.runtime.tick += 1 * * The transaction and row lock are already in place so later work can add * movement/effects without changing the request architecture. */ /* |-------------------------------------------------------------------------- | Linear Location crossing | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | A tick may move farther than the distance to the next authored Location. | This resolver finds the FIRST active Location crossed in the direction of | travel. The movement is then clamped to that exact authored coordinate. | | Examples: | 45 -> 55, Location 50 => stop at 50 | 55 -> 45, Location 50 => stop at 50 | | The starting coordinate is intentionally exclusive so leaving a Location | does not immediately trigger that same Location again. | */ /* |-------------------------------------------------------------------------- | Location Conditions | WAYFARER_LOCATION_CONDITIONS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeLocationConditionsPass( PDO $pdo, int $gameId, array $location, array &$runtimeState ): bool { $config = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $conditions = $config['conditions'] ?? null; $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; return wayfarerConditionsPass( $conditions, $resolver ); } /* |-------------------------------------------------------------------------- | Game Endings | WAYFARER_GAME_ENDINGS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeEvaluateGameEndings( PDO $pdo, int $gameId, array &$runtimeState ): ?array { /* * WAYFARER_ALL_REGION_STATE_ENDINGS_V46 */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $runtimeState ); /* WAYFARER_RECOVER_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $runtimeState ); if ( isset( $runtimeState['runtime']['ending'] ) && is_array( $runtimeState['runtime']['ending'] ) ) { return $runtimeState['runtime']['ending']; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); $config = wayfarerRuntimeDecodeConfig( $raw === false ? null : $raw ); $endings = $config['endings'] ?? []; if (!is_array($endings)) { return null; } $ordered = []; foreach ( array_values($endings) as $index => $ending ) { if (!is_array($ending)) { continue; } $ending['_runtime_order'] = $index; $ordered[] = $ending; } usort( $ordered, function ( array $a, array $b ): int { $priorityCompare = (int)( $b['priority'] ?? 0 ) <=> (int)( $a['priority'] ?? 0 ); if ($priorityCompare !== 0) { return $priorityCompare; } return (int)( $a['_runtime_order'] ?? 0 ) <=> (int)( $b['_runtime_order'] ?? 0 ); } ); $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; foreach ($ordered as $ending) { if ( !wayfarerConditionsPass( $ending['conditions'] ?? null, $resolver ) ) { continue; } $terminal = [ 'id' => (string)( $ending['id'] ?? '' ), 'title' => (string)( $ending['title'] ?? 'The End' ), 'text' => (string)( $ending['text'] ?? '' ), 'image_media_id' => (int)( $ending['image_media_id'] ?? 0 ), 'priority' => (int)( $ending['priority'] ?? 0 ), 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), ]; if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['ending'] = $terminal; unset( $runtimeState['runtime']['presentation'] ); return $terminal; } return null; } function wayfarerRuntimeFindCrossedLocation( PDO $pdo, int $gameId, int $regionId, $fromPosition, $toPosition, array &$runtimeState ): ?array { /* * WAYFARER_CROSSED_LOCATION_STATE_FIX_V46_1 * * Location Conditions are evaluated against the live runtime state. */ if ( $gameId <= 0 || $regionId <= 0 || !is_numeric($fromPosition) || !is_numeric($toPosition) ) { return null; } $from = (float)$fromPosition; $to = (float)$toPosition; if ($from === $to) { return null; } /* * Crossing interception currently applies only to Linear Regions. */ $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); $forward = $to > $from; $candidate = null; $candidatePosition = null; foreach ($locations as $location) { $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $locationPosition = (float)$config['position']; /* * The starting point is exclusive; the destination is inclusive. */ $crossed = $forward ? ( $locationPosition > $from && $locationPosition <= $to ) : ( $locationPosition < $from && $locationPosition >= $to ); if (!$crossed) { continue; } $location['_runtime_config'] = $config; if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } if ($candidate === null) { $candidate = $location; $candidatePosition = $locationPosition; } else { $nearer = $forward ? ( $locationPosition < $candidatePosition ) : ( $locationPosition > $candidatePosition ); $samePositionLowerId = $locationPosition === $candidatePosition && (int)$location['id'] < (int)$candidate['id']; if ( $nearer || $samePositionLowerId ) { $candidate = $location; $candidatePosition = $locationPosition; } } } if ($candidate === null) { return null; } $candidate['_runtime_position'] = wayfarerNormalizeRuntimeNumber( (float)$candidatePosition ); $candidate['_runtime_config'] = wayfarerRuntimeDecodeConfig( $candidate['config'] ?? null ); return $candidate; } /* |-------------------------------------------------------------------------- | Player spatial context |-------------------------------------------------------------------------- | WAYFARER_PLAYER_CONTEXT_V46_1 | | This is the canonical player-facing spatial label for Action Mode: | | exact eligible Location -> Location name + Region parent context | otherwise -> Region name | | It is returned by runtime APIs so the browser does not keep displaying the | Location name from the page's original server render after movement. | */ function wayfarerRuntimePlayerContext( PDO $pdo, int $gameId, array $runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionName = ''; if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $rawRegionName = $stmt->fetchColumn(); if ($rawRegionName !== false) { $regionName = trim( (string)$rawRegionName ); } } $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); $locationId = 0; $locationName = ''; if ($location) { $locationId = (int)( $location['id'] ?? 0 ); $locationName = trim( (string)( $location['name'] ?? '' ) ); } $contextName = $locationName !== '' ? $locationName : ( $regionName !== '' ? $regionName : 'Actions' ); return [ 'region_id' => $regionId, 'region_name' => $regionName, 'location_id' => $locationId, 'location_name' => $locationName, 'context_name' => $contextName, 'parent_context' => $locationName !== '' ? $regionName : '', ]; } /* |-------------------------------------------------------------------------- | Presentation dismissal |-------------------------------------------------------------------------- | | Presentation is persisted runtime state. This function is intentionally | generic even though v40 renders only Location presentations; Storylets can | use the same mechanism later. | */ function wayfarerRuntimeDismissPresentation( PDO $pdo, int $gameUserId, string $expectedType = '', int $expectedId = 0 ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); $presentation = $state['runtime']['presentation'] ?? null; if ( !is_array($presentation) || empty($presentation['type']) || empty($presentation['id']) ) { throw new RuntimeException( 'There is no active presentation to continue.' ); } if ( $expectedType !== '' && (string)$presentation['type'] !== $expectedType ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } if ( $expectedId > 0 && (int)$presentation['id'] !== $expectedId ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } /* WAYFARER_STORYLET_BRANCH_DISMISS_GUARD_V59 */ if ( (string)($presentation['type'] ?? '') === 'storylet' ) { $eligibleBranches = wayfarerRuntimeEligibleStoryletBranches( $pdo, (int)$row['game_id'], (int)($presentation['id'] ?? 0), $state ); if ($eligibleBranches) { throw new RuntimeException( 'Choose an available Storylet Branch before continuing.' ); } } $state['runtime']['last_presentation'] = $presentation; unset( $state['runtime']['presentation'] ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'dismissed' => $presentation, /* WAYFARER_STORYLET_ACTIONS_AFTER_PRESENTATION_V59 */ 'actions' => wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$row['game_id'], $state ), ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Advance exactly one tick. * * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ function wayfarerRuntimeAdvanceTick( PDO $pdo, int $gameUserId, ?int $requestedTravelDirection = null ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } /* WAYFARER_BIDIRECTIONAL_TRAVEL_V71 */ if ( !isset($state['navigation']) || !is_array($state['navigation']) ) { $state['navigation'] = []; } if ( $requestedTravelDirection !== null && !in_array( $requestedTravelDirection, [-1, 1], true ) ) { throw new RuntimeException( 'Travel direction must be -1 or 1.' ); } $state['navigation']['direction'] = $requestedTravelDirection !== null ? $requestedTravelDirection : wayfarerRuntimeNavigationDirection( $state ); $state['schema_version'] = max( 2, (int)( $state['schema_version'] ?? 1 ) ); /* * WAYFARER_GAME_ENDINGS_TERMINAL_V45 * * Endings are terminal runtime state. */ if ( isset( $state['runtime']['ending'] ) && is_array( $state['runtime']['ending'] ) ) { throw new RuntimeException( 'This game has ended.' ); } /* * Narrative presentation is a true runtime pause, not merely a hidden * browser control. Even Author Mode cannot advance ticks through it. */ if ( isset( $state['runtime']['presentation'] ) && is_array( $state['runtime']['presentation'] ) ) { throw new RuntimeException( 'Continue the current presentation before advancing time.' ); } /* * WAYFARER_ALL_REGION_STATE_TICK_V46 * * Recovery path for playthroughs initialized before v46, and for * Regions/Traits authored after a playthrough began. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_LOCATION_STATE_TICK_RECOVERY_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, (int)$row['game_id'], $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_TICK_V49 * * Recovery path for playthroughs created before V49 and for Entities * or Traits authored after play began. */ wayfarerRuntimeEnsureEntityState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_PLAYER_TRAITS_TICK_RECOVERY_V52 */ wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$row['game_id'], $state ); $regionIdBeforeTick = (int)( $state['navigation']['region_id'] ?? 0 ); $positionBeforeTick = $state['navigation']['position'] ?? null; $currentTick = (int)( $state['runtime']['tick'] ?? 0 ); $state['runtime']['tick'] = $currentTick + 1; /* WAYFARER_ADVANCE_GAME_CLOCK_V61 */ wayfarerRuntimeAdvanceClock( $pdo, (int)$row['game_id'], $state ); /* * Global Game Tick Effects run first. */ wayfarerApplyGameTickEffects( $pdo, (int)$row['game_id'], $state ); /* * Current Region Tick Effects then perform Region-level movement and * state changes. */ wayfarerApplyCurrentRegionTickEffects( $pdo, $state ); /* * Resolve whether the completed movement would have crossed an active * Location. If so, stop at the nearest crossed Location rather than * accepting the overshot position. */ $regionIdAfterMovement = (int)( $state['navigation']['region_id'] ?? 0 ); $positionAfterMovement = $state['navigation']['position'] ?? null; $crossedLocation = null; /* * We only interpret movement as a continuous path while the player * remains in the same Region. Region transitions will later get their * own explicit navigation semantics. */ if ( $regionIdBeforeTick > 0 && $regionIdBeforeTick === $regionIdAfterMovement ) { $crossedLocation = wayfarerRuntimeFindCrossedLocation( $pdo, (int)$row['game_id'], $regionIdAfterMovement, $positionBeforeTick, $positionAfterMovement, $state ); } $arrivalPosition = null; if ($crossedLocation) { $arrivalPosition = $crossedLocation[ '_runtime_position' ]; $state['navigation']['position'] = $arrivalPosition; /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Event Effects execute once at the trigger boundary. They run * before the Location's Tick Effects and before Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, (int)$row['game_id'], $crossedLocation, $state ); } /* * WAYFARER_LOCATION_EFFECTS_ONLY_RUNTIME_V43 * * Locations no longer own Tick Effects. Game and Region Tick Effects * have already advanced the simulation; a crossed Location contributes * its one-time Effects and then interrupts with Presentation Mode. */ if ($crossedLocation) { $state['navigation']['position'] = $arrivalPosition; $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$crossedLocation['id'], 'phase' => 'presentation', 'trigger' => 'arrival', 'tick' => (int)$state['runtime']['tick'], ]; $state['runtime']['last_location_arrival'] = [ 'location_id' => (int)$crossedLocation['id'], 'region_id' => $regionIdAfterMovement, 'position' => $arrivalPosition, 'tick' => (int)$state['runtime']['tick'], ]; } /* WAYFARER_GAME_ENDINGS_AFTER_TICK_V45 */ $ending = wayfarerRuntimeEvaluateGameEndings( $pdo, (int)$row['game_id'], $state ); $state['runtime']['last_tick_at'] = gmdate('c'); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId ]); $pdo->commit(); $presentation = $state['runtime']['presentation'] ?? null; $pausedForPresentation = is_array($presentation) && !empty($presentation['type']) && !empty($presentation['id']); $ending = $state['runtime']['ending'] ?? null; $ended = is_array($ending) && !empty($ending['id']); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'tick' => (int)$state['runtime']['tick'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'pause' => $pausedForPresentation || $ended, 'pause_reason' => $ended ? 'Game ended.' : ( $pausedForPresentation ? 'Location reached.' : '' ), 'presentation' => $pausedForPresentation ? $presentation : null, 'ending' => $ended ? $ending : null, /* WAYFARER_STORYLET_ACTIONS_TICK_API_V59 */ 'actions' => !$pausedForPresentation && !$ended ? wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$row['game_id'], $state ) : [], /* WAYFARER_TRAVEL_TIME_HUD_V61 */ 'travel' => wayfarerRuntimeBuildTravelStatus( $pdo, (int)$row['game_id'], $state ), ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: _wayfarer_backups/v88-20260929-205950/play/includes/statuses.php ================================================================================ 0; } return in_array( strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on', 'active'], true ); } function wayfarerRuntimeStatusDefinitions( PDO $pdo, int $gameId, bool $activeOnly = true ): array { if ($gameId <= 0) { return []; } $sql = " SELECT id, name, description, config, status FROM wf_statuses WHERE game_id = ? "; if ($activeOnly) { $sql .= " AND status = 'active'"; } $sql .= " ORDER BY name, id"; $stmt = $pdo->prepare($sql); $stmt->execute([$gameId]); $definitions = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $config = []; if (!empty($row['config'])) { $decoded = json_decode((string)$row['config'], true); if (is_array($decoded)) { $config = $decoded; } } $showToPlayer = !array_key_exists('show_to_player', $config) || wayfarerRuntimeStatusNormalizeBoolean($config['show_to_player']); $definitions[(string)$id] = [ 'id' => $id, 'name' => trim((string)($row['name'] ?? ('Status ' . $id))), 'description' => trim((string)($row['description'] ?? '')), 'status' => strtolower(trim((string)($row['status'] ?? 'draft'))), 'show_to_player' => $showToPlayer, ]; } return $definitions; } function wayfarerRuntimeStatusDefinition( PDO $pdo, int $gameId, int $statusId, bool $activeOnly = true ): ?array { if ($statusId <= 0) { return null; } $definitions = wayfarerRuntimeStatusDefinitions( $pdo, $gameId, $activeOnly ); return $definitions[(string)$statusId] ?? null; } function wayfarerRuntimeStatusScopeAddress( array $runtimeState, string $scope ): ?array { $scope = strtolower(trim($scope)); if ( preg_match('/^player:([0-9]+)$/', $scope, $match) === 1 ) { $key = (string)((int)$match[1]); if (!isset($runtimeState['players'][$key]) || !is_array($runtimeState['players'][$key])) { return null; } return [ 'bucket' => 'players', 'key' => $key, ]; } if ( preg_match('/^entity:([1-9][0-9]*)$/', $scope, $match) === 1 ) { $key = (string)((int)$match[1]); if (!isset($runtimeState['entities'][$key]) || !is_array($runtimeState['entities'][$key])) { return null; } return [ 'bucket' => 'entities', 'key' => $key, ]; } return null; } function wayfarerRuntimeStatusState( array $runtimeState, string $scope, int $statusId ): array { $address = wayfarerRuntimeStatusScopeAddress( $runtimeState, $scope ); if ($address === null || $statusId <= 0) { return [ 'found_owner' => false, 'active' => false, 'started_tick' => null, ]; } $stored = $runtimeState[$address['bucket']][$address['key']]['statuses'][(string)$statusId] ?? null; if (!is_array($stored)) { return [ 'found_owner' => true, 'active' => false, 'started_tick' => null, ]; } return [ 'found_owner' => true, 'active' => !empty($stored['active']), 'started_tick' => isset($stored['started_tick']) && is_numeric($stored['started_tick']) ? (int)$stored['started_tick'] : null, ]; } function wayfarerRuntimeSetStatus( array &$runtimeState, string $scope, int $statusId, bool $active ): array { $address = wayfarerRuntimeStatusScopeAddress( $runtimeState, $scope ); if ($address === null || $statusId <= 0) { return [ 'applied' => false, 'before' => null, 'after' => null, 'started_tick' => null, 'error' => 'Status owner was not found in runtime state.', ]; } $bucket = $address['bucket']; $key = $address['key']; if (!isset($runtimeState[$bucket][$key]['statuses']) || !is_array($runtimeState[$bucket][$key]['statuses'])) { $runtimeState[$bucket][$key]['statuses'] = []; } $statusKey = (string)$statusId; $existing = $runtimeState[$bucket][$key]['statuses'][$statusKey] ?? []; if (!is_array($existing)) { $existing = []; } $before = !empty($existing['active']); $startedTick = isset($existing['started_tick']) && is_numeric($existing['started_tick']) ? (int)$existing['started_tick'] : null; if ($active && !$before) { $startedTick = (int)( $runtimeState['runtime']['tick'] ?? 0 ); } $runtimeState[$bucket][$key]['statuses'][$statusKey] = [ 'active' => $active, 'started_tick' => $startedTick, ]; return [ 'applied' => true, 'before' => $before ? 1 : 0, 'after' => $active ? 1 : 0, 'started_tick' => $startedTick, 'error' => '', ]; } function wayfarerRuntimeVisibleStatusesForScope( PDO $pdo, int $gameId, array $runtimeState, string $scope ): array { $address = wayfarerRuntimeStatusScopeAddress( $runtimeState, $scope ); if ($address === null) { return []; } $definitions = wayfarerRuntimeStatusDefinitions( $pdo, $gameId, true ); if ($definitions === []) { return []; } $storedStatuses = $runtimeState[$address['bucket']][$address['key']]['statuses'] ?? []; if (!is_array($storedStatuses)) { return []; } $visible = []; foreach ($definitions as $statusKey => $definition) { if (empty($definition['show_to_player'])) { continue; } $stored = $storedStatuses[$statusKey] ?? null; if (!is_array($stored) || empty($stored['active'])) { continue; } $visible[] = [ 'id' => (int)$definition['id'], 'name' => (string)$definition['name'], 'description' => (string)$definition['description'], 'started_tick' => isset($stored['started_tick']) && is_numeric($stored['started_tick']) ? (int)$stored['started_tick'] : null, ]; } return $visible; } ================================================================================ FILE: _wayfarer_backups/v88-20260929-205950/play/includes/storylet-bindings.php ================================================================================ $key, 'label' => $label !== '' ? $label : $key, 'selector' => $selector, ]; } return $bindings; } function wayfarerRuntimeStoryletBindingRequiredTargets( array $storylet, string $bindingKey ): array { $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); $effects = isset($config['effects']) && is_array($config['effects']) ? $config['effects'] : []; $scope = 'binding:' . $bindingKey; $required = []; foreach ($effects as $effect) { if (!is_array($effect)) { continue; } if ( strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ) !== $scope ) { continue; } $targetType = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); if ( $targetType === '' || $targetId === '' ) { continue; } $required[ $targetType . ':' . $targetId ] = [ 'target_type' => $targetType, 'target_id' => $targetId, ]; } return array_values($required); } function wayfarerRuntimeStoryletBindingFindNamedTrait( array $config, string $normalizedName ): ?array { $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; $match = null; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) || ( array_key_exists( 'active', $trait ) && !$trait['active'] ) ) { continue; } $name = wayfarerRuntimeStoryletBindingNormalizeTraitName( (string)( $trait['name'] ?? '' ) ); if ($name !== $normalizedName) { continue; } /* * Ambiguous duplicate names on one Entity are not safe for a generic * binding target. */ if ($match !== null) { return null; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { return null; } $match = $trait; } return $match; } function wayfarerRuntimeStoryletBindingEntitySupportsTargets( array $entity, array $requiredTargets ): array { $config = wayfarerEntityDecodeConfig( $entity['config'] ?? null ); $targetMap = []; foreach ($requiredTargets as $required) { if (!is_array($required)) { continue; } $targetType = strtolower( trim( (string)( $required['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $required['target_id'] ?? '' ) ); if ( $targetType === 'status' && preg_match('/^[1-9][0-9]*$/', $targetId) === 1 ) { $targetMap[$targetId] = $targetId; continue; } if ( $targetType !== 'trait' || $targetId === '' ) { return [ 'supported' => false, 'target_map' => [], ]; } if ( $targetId === 'sys_item_quantity' || $targetId === 'sys_character_party_member' ) { $targetMap[$targetId] = $targetId; continue; } $namedTrait = wayfarerRuntimeStoryletBindingDecodeNamedTraitId( $targetId ); if ($namedTrait === null) { return [ 'supported' => false, 'target_map' => [], ]; } $trait = wayfarerRuntimeStoryletBindingFindNamedTrait( $config, $namedTrait ); if (!is_array($trait)) { return [ 'supported' => false, 'target_map' => [], ]; } $actualTraitId = trim( (string)( $trait['id'] ?? '' ) ); if ($actualTraitId === '') { return [ 'supported' => false, 'target_map' => [], ]; } $targetMap[$targetId] = $actualTraitId; } return [ 'supported' => true, 'target_map' => $targetMap, ]; } /* WAYFARER_PARTY_BINDINGS_V85 */ function wayfarerRuntimeStoryletBindingFindPlayerTrait( array $gameConfig, string $normalizedName ): ?array { $traits = isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : []; $match = null; foreach ($traits as $trait) { if ( !is_array($trait) || ( array_key_exists('active', $trait) && !$trait['active'] ) ) { continue; } $name = wayfarerRuntimeStoryletBindingNormalizeTraitName( (string)( $trait['name'] ?? '' ) ); if ($name !== $normalizedName) { continue; } if ($match !== null) { return null; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { return null; } $match = $trait; } return $match; } function wayfarerRuntimeStoryletBindingPlayerSupportsTargets( array $gameConfig, array $playerState, array $requiredTargets ): array { $targetMap = []; $runtimeTraits = isset($playerState['traits']) && is_array($playerState['traits']) ? $playerState['traits'] : []; foreach ($requiredTargets as $required) { if (!is_array($required)) { continue; } $targetType = strtolower( trim( (string)( $required['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $required['target_id'] ?? '' ) ); if ( $targetType === 'status' && preg_match('/^[1-9][0-9]*$/', $targetId) === 1 ) { $targetMap[$targetId] = $targetId; continue; } if ( $targetType !== 'trait' || $targetId === '' || $targetId === 'sys_item_quantity' || $targetId === 'sys_character_party_member' ) { return [ 'supported' => false, 'target_map' => [], ]; } $namedTrait = wayfarerRuntimeStoryletBindingDecodeNamedTraitId( $targetId ); if ($namedTrait === null) { return [ 'supported' => false, 'target_map' => [], ]; } $trait = wayfarerRuntimeStoryletBindingFindPlayerTrait( $gameConfig, $namedTrait ); if (!is_array($trait)) { return [ 'supported' => false, 'target_map' => [], ]; } $actualTraitId = trim( (string)( $trait['id'] ?? '' ) ); if ( $actualTraitId === '' || !array_key_exists( $actualTraitId, $runtimeTraits ) ) { return [ 'supported' => false, 'target_map' => [], ]; } $targetMap[$targetId] = $actualTraitId; } return [ 'supported' => true, 'target_map' => $targetMap, ]; } function wayfarerRuntimeStoryletBindingCandidates( PDO $pdo, int $gameId, array $storylet, array $binding, array $runtimeState ): array { $selector = strtolower( trim( (string)( $binding['selector'] ?? '' ) ) ); $bindingKey = wayfarerRuntimeStoryletBindingNormalizeKey( (string)( $binding['key'] ?? '' ) ); $requiredTargets = wayfarerRuntimeStoryletBindingRequiredTargets( $storylet, $bindingKey ); $candidates = []; /* * Named setup Player Characters are party members too. They do not use the * Entity Party Membership Trait; their presence in runtime.players is the * canonical membership signal for the playthrough. */ if ($selector === 'random_party_character') { $gameConfig = function_exists('wayfarerRuntimeLoadGameConfig') ? wayfarerRuntimeLoadGameConfig( $pdo, $gameId ) : []; $players = isset($runtimeState['players']) && is_array($runtimeState['players']) ? $runtimeState['players'] : []; foreach ($players as $playerKey => $playerState) { if (!is_array($playerState)) { continue; } $name = trim( (string)( $playerState['name'] ?? '' ) ); if ($name === '') { continue; } $support = wayfarerRuntimeStoryletBindingPlayerSupportsTargets( $gameConfig, $playerState, $requiredTargets ); if (empty($support['supported'])) { continue; } $slot = (int)( $playerState['slot'] ?? $playerKey ); $candidates[] = [ 'runtime_scope' => 'player:' . $slot, 'subject_kind' => 'player', 'player_slot' => $slot, 'entity_id' => 0, 'entity_type' => 'player', 'name' => $name, 'target_map' => $support['target_map'], ]; } } foreach ( wayfarerRuntimeLoadActiveEntities( $pdo, $gameId ) as $entity ) { if (!is_array($entity)) { continue; } $entityId = (int)( $entity['id'] ?? 0 ); if ($entityId <= 0) { continue; } $entityType = strtolower( trim( (string)( $entity['entity_type'] ?? '' ) ) ); $entityState = $runtimeState['entities'][ (string)$entityId ] ?? null; if (!is_array($entityState)) { continue; } $traits = isset($entityState['traits']) && is_array($entityState['traits']) ? $entityState['traits'] : []; if ( $selector === 'random_party_character' ) { if ( $entityType !== 'character' || empty( $traits[ 'sys_character_party_member' ] ) ) { continue; } } elseif ( $selector === 'random_possessed_item' ) { $quantity = $traits[ 'sys_item_quantity' ] ?? 0; if ( $entityType !== 'item' || !is_numeric($quantity) || (float)$quantity <= 0 ) { continue; } } else { continue; } $support = wayfarerRuntimeStoryletBindingEntitySupportsTargets( $entity, $requiredTargets ); if (empty($support['supported'])) { continue; } $candidates[] = [ 'runtime_scope' => 'entity:' . $entityId, 'subject_kind' => 'entity', 'player_slot' => null, 'entity_id' => $entityId, 'entity_type' => $entityType, 'name' => trim( (string)( $entity['name'] ?? ('Entity ' . $entityId) ) ), 'target_map' => $support['target_map'], ]; } return $candidates; } function wayfarerRuntimeStoryletBindingsAvailable( PDO $pdo, int $gameId, array $storylet, array $runtimeState ): bool { $definitions = wayfarerRuntimeStoryletBindingDefinitions( $storylet ); foreach ($definitions as $binding) { if ( wayfarerRuntimeStoryletBindingCandidates( $pdo, $gameId, $storylet, $binding, $runtimeState ) === [] ) { return false; } } return true; } function wayfarerRuntimeResolveStoryletBindings( PDO $pdo, int $gameId, array $storylet, array $runtimeState ): array { $definitions = wayfarerRuntimeStoryletBindingDefinitions( $storylet ); $resolved = []; foreach ( $definitions as $key => $binding ) { $candidates = wayfarerRuntimeStoryletBindingCandidates( $pdo, $gameId, $storylet, $binding, $runtimeState ); if ($candidates === []) { return [ 'bindings' => [], 'error' => 'Storylet Binding "' . ( $binding['label'] ?? $key ) . '" has no eligible runtime target.', ]; } $selected = $candidates[ random_int( 0, count($candidates) - 1 ) ]; $resolved[$key] = [ 'key' => $key, 'label' => (string)( $binding['label'] ?? $key ), 'selector' => (string)( $binding['selector'] ?? '' ), 'runtime_scope' => (string)( $selected['runtime_scope'] ?? '' ), 'subject_kind' => (string)( $selected['subject_kind'] ?? 'entity' ), 'player_slot' => isset($selected['player_slot']) ? (int)$selected['player_slot'] : null, 'entity_id' => (int)( $selected['entity_id'] ?? 0 ), 'entity_type' => (string)( $selected['entity_type'] ?? '' ), 'name' => (string)$selected['name'], 'target_map' => is_array( $selected['target_map'] ?? null ) ? $selected['target_map'] : [], ]; } return [ 'bindings' => $resolved, 'error' => '', ]; } function wayfarerRuntimeMaterializeStoryletBindingEffect( array $effect, array $bindings ): array { $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); if ( preg_match( '/^binding:([a-z][a-z0-9_]{0,31})$/', $scope, $match ) !== 1 ) { return [ 'effect' => $effect, 'error' => '', ]; } $key = $match[1]; $binding = $bindings[$key] ?? null; if (!is_array($binding)) { return [ 'effect' => [], 'error' => 'Resolved Storylet Binding "' . $key . '" is missing.', ]; } $runtimeScope = strtolower( trim( (string)( $binding['runtime_scope'] ?? '' ) ) ); /* * Compatibility for V79-V84 saved runtime binding records, which stored * only entity_id. */ if ($runtimeScope === '') { $entityId = (int)( $binding['entity_id'] ?? 0 ); if ($entityId > 0) { $runtimeScope = 'entity:' . $entityId; } } if ( preg_match( '/^(?:entity:[1-9][0-9]*|player:[0-9]+)$/', $runtimeScope ) !== 1 ) { return [ 'effect' => [], 'error' => 'Resolved Storylet Binding "' . $key . '" does not point to a valid Party Member or Entity.', ]; } $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); $targetType = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); /* WAYFARER_STATUS_BINDING_RUNTIME_V87 */ if ( $targetType === 'status' && preg_match('/^[1-9][0-9]*$/', $targetId) === 1 ) { $materialized = $effect; $materialized['target_scope'] = $runtimeScope; $materialized['target_id'] = $targetId; return [ 'effect' => $materialized, 'error' => '', ]; } $targetMap = is_array( $binding['target_map'] ?? null ) ? $binding['target_map'] : []; $entityScope = strpos( $runtimeScope, 'entity:' ) === 0; $actualTargetId = $targetMap[$targetId] ?? ( $entityScope && ( $targetId === 'sys_item_quantity' || $targetId === 'sys_character_party_member' ) ? $targetId : '' ); if ($actualTargetId === '') { return [ 'effect' => [], 'error' => 'The selected subject for Binding "' . $key . '" does not expose the required Trait.', ]; } $materialized = $effect; $materialized['target_scope'] = $runtimeScope; $materialized['target_id'] = $actualTargetId; if ( $entityScope && $actualTargetId === 'sys_item_quantity' ) { $materialized['target_system_key'] = 'quantity'; } elseif ( $entityScope && $actualTargetId === 'sys_character_party_member' ) { $materialized['target_system_key'] = 'party_member'; } else { unset( $materialized['target_system_key'] ); } return [ 'effect' => $materialized, 'error' => '', ]; } function wayfarerRuntimeInterpolateStoryletBindings( string $text, array $bindings ): string { if ( $text === '' || $bindings === [] ) { return $text; } $replace = function ( array $match ) use ( $bindings ): string { $key = wayfarerRuntimeStoryletBindingNormalizeKey( (string)( $match[1] ?? '' ) ); $binding = $bindings[$key] ?? null; if (!is_array($binding)) { return $match[0]; } return (string)( $binding['name'] ?? $match[0] ); }; /* * {{victim.name}} and the shorter {{victim}} both resolve to the bound * subject's current name. */ $text = preg_replace_callback( '/\{\{\s*([a-z][a-z0-9_]*)\.name\s*\}\}/i', $replace, $text ) ?? $text; $text = preg_replace_callback( '/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/i', $replace, $text ) ?? $text; return $text; } ================================================================================ FILE: _wayfarer_backups/v88-20260929-205950/play/includes/storylets.php ================================================================================ root Manual Storylet -> Storylet presentation | -> eligible child Branch -> child presentation -> ... | | Automatic/Chance firing is intentionally not handled here yet. | */ function wayfarerRuntimeLoadStorylet( PDO $pdo, int $gameId, int $storyletId ): ?array { if ($gameId <= 0 || $storyletId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT id, game_id, name, description, parent_storylet_id, branch_label, branch_sort_order, trigger_type, max_occurrences, status, config FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $storyletId, $gameId, ]); $storylet = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$storylet) { return null; } $storylet['_runtime_config'] = wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); return $storylet; } function wayfarerRuntimeStoryletOccurrenceCount( array $runtimeState, int $storyletId ): int { if ($storyletId <= 0) { return 0; } return max( 0, (int)( $runtimeState['storylets'][(string)$storyletId]['occurrences'] ?? 0 ) ); } function wayfarerRuntimeStoryletOccurrenceLimitReached( array $storylet, array $runtimeState ): bool { $maximum = $storylet['max_occurrences'] ?? null; if ( $maximum === null || $maximum === '' || !is_numeric($maximum) ) { return false; } $maximum = (int)$maximum; if ($maximum <= 0) { return false; } return wayfarerRuntimeStoryletOccurrenceCount( $runtimeState, (int)($storylet['id'] ?? 0) ) >= $maximum; } function wayfarerRuntimeStoryletConditionsPass( array $storylet, array &$runtimeState ): bool { $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); $conditions = $config['conditions'] ?? null; $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; return wayfarerConditionsPass( $conditions, $resolver ); } function wayfarerRuntimeStoryletIsEligible( array $storylet, array &$runtimeState ): bool { if ( strtolower( trim( (string)( $storylet['status'] ?? '' ) ) ) !== 'active' ) { return false; } if ( wayfarerRuntimeStoryletOccurrenceLimitReached( $storylet, $runtimeState ) ) { return false; } return wayfarerRuntimeStoryletConditionsPass( $storylet, $runtimeState ); } function wayfarerRuntimeStoryletPlayerPayload( array $storylet, array $runtimeState, bool $asBranch = false ): array { $name = trim( (string)( $storylet['name'] ?? '' ) ); $branchLabel = trim( (string)( $storylet['branch_label'] ?? '' ) ); $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); return [ 'id' => (int)( $storylet['id'] ?? 0 ), 'name' => $name, 'label' => $asBranch && $branchLabel !== '' ? $branchLabel : $name, 'description' => trim( (string)( $storylet['description'] ?? '' ) ), 'priority' => (int)( $config['priority'] ?? 100 ), 'occurrences' => wayfarerRuntimeStoryletOccurrenceCount( $runtimeState, (int)( $storylet['id'] ?? 0 ) ), 'max_occurrences' => $storylet['max_occurrences'] === null || $storylet['max_occurrences'] === '' ? null : (int)$storylet['max_occurrences'], ]; } function wayfarerRuntimeEligibleRootManualStorylets( PDO $pdo, int $gameId, array &$runtimeState ): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare( " SELECT id, game_id, name, description, parent_storylet_id, branch_label, branch_sort_order, trigger_type, max_occurrences, status, config FROM wf_storylets WHERE game_id = ? AND status = 'active' AND trigger_type = 'manual' AND ( parent_storylet_id IS NULL OR parent_storylet_id = 0 ) ORDER BY id " ); $stmt->execute([ $gameId ]); $actions = []; foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $storylet ) { $storylet['_runtime_config'] = wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $runtimeState ) || !wayfarerRuntimeStoryletBindingsAvailable( $pdo, $gameId, $storylet, $runtimeState ) ) { continue; } $actions[] = wayfarerRuntimeStoryletPlayerPayload( $storylet, $runtimeState, false ); } usort( $actions, function ( array $a, array $b ): int { $priority = ((int)($a['priority'] ?? 100)) <=> ((int)($b['priority'] ?? 100)); if ($priority !== 0) { return $priority; } $label = strcasecmp( (string)($a['label'] ?? ''), (string)($b['label'] ?? '') ); if ($label !== 0) { return $label; } return ((int)($a['id'] ?? 0)) <=> ((int)($b['id'] ?? 0)); } ); return $actions; } function wayfarerRuntimeEligibleStoryletBranches( PDO $pdo, int $gameId, int $parentStoryletId, array &$runtimeState ): array { if ( $gameId <= 0 || $parentStoryletId <= 0 ) { return []; } $stmt = $pdo->prepare( " SELECT id, game_id, name, description, parent_storylet_id, branch_label, branch_sort_order, trigger_type, max_occurrences, status, config FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? AND status = 'active' ORDER BY branch_sort_order, name, id " ); $stmt->execute([ $gameId, $parentStoryletId, ]); $branches = []; foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $storylet ) { $storylet['_runtime_config'] = wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $runtimeState ) || !wayfarerRuntimeStoryletBindingsAvailable( $pdo, $gameId, $storylet, $runtimeState ) ) { continue; } $payload = wayfarerRuntimeStoryletPlayerPayload( $storylet, $runtimeState, true ); $payload['branch_sort_order'] = (int)( $storylet['branch_sort_order'] ?? 0 ); $branches[] = $payload; } return $branches; } function wayfarerRuntimeEnsureStoryletTargetState( PDO $pdo, int $gameId, array &$runtimeState ): void { wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $runtimeState ); wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $runtimeState ); wayfarerRuntimeEnsureEntityState( $pdo, $gameId, $runtimeState ); wayfarerRuntimeEnsurePlayerAndGameState( $pdo, $gameId, $runtimeState ); } function wayfarerRuntimeStoryletTraitDefinitions( PDO $pdo, int $gameId, array $runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionConfig = []; if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $raw = $stmt->fetchColumn(); if ($raw !== false) { $regionConfig = wayfarerRuntimeDecodeTraitConfig( $raw ); } } return wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); } /* |-------------------------------------------------------------------------- | Storylet-to-root navigation runtime | WAYFARER_STORYLET_ROOT_CONNECTIONS_V82 |-------------------------------------------------------------------------- */ function wayfarerRuntimeConsumeStoryletTransition( array &$runtimeState ): int { $storyletId = (int)( $runtimeState['runtime']['pending_storylet_transition'] ?? 0 ); if ( isset($runtimeState['runtime']) && is_array($runtimeState['runtime']) ) { unset( $runtimeState['runtime']['pending_storylet_transition'] ); } return max( 0, $storyletId ); } function wayfarerRuntimeEnterLinkedRootStorylet( PDO $pdo, int $gameId, int $storyletId, array &$runtimeState, array $visitedStorylets = [] ): array { $chain = []; $nextStoryletId = $storyletId; while ($nextStoryletId > 0) { if (count($chain) >= 16) { throw new RuntimeException( 'Storylet Continue To chain is too deep.' ); } if ( isset( $visitedStorylets[ (string)$nextStoryletId ] ) ) { throw new RuntimeException( 'Storylet Continue To would create a circular Storylet chain.' ); } $visitedStorylets[ (string)$nextStoryletId ] = true; $linkedStorylet = wayfarerRuntimeLoadStorylet( $pdo, $gameId, $nextStoryletId ); if (!$linkedStorylet) { throw new RuntimeException( 'The linked root Storylet could not be found.' ); } if ( !empty( $linkedStorylet[ 'parent_storylet_id' ] ) ) { throw new RuntimeException( 'Storylet Continue To may target only a root Storylet.' ); } wayfarerRuntimeEnsureStoryletTargetState( $pdo, $gameId, $runtimeState ); if ( !wayfarerRuntimeStoryletIsEligible( $linkedStorylet, $runtimeState ) || !wayfarerRuntimeStoryletBindingsAvailable( $pdo, $gameId, $linkedStorylet, $runtimeState ) ) { throw new RuntimeException( 'The linked root Storylet is not currently eligible.' ); } $linkedConfig = $linkedStorylet[ '_runtime_config' ] ?? []; $tickCost = max( 0, (int)( $linkedConfig[ 'tick_cost' ] ?? 0 ) ); if ($tickCost > 0) { throw new RuntimeException( 'Storylet Tick Cost runtime is not implemented yet. Set Tick Cost to 0 for linked Storylets.' ); } $bindingResult = wayfarerRuntimeResolveStoryletBindings( $pdo, $gameId, $linkedStorylet, $runtimeState ); if ( ($bindingResult['error'] ?? '') !== '' ) { throw new RuntimeException( (string)$bindingResult['error'] ); } $resolvedBindings = is_array( $bindingResult['bindings'] ?? null ) ? $bindingResult['bindings'] : []; $effects = wayfarerRuntimeApplyStoryletEffects( $pdo, $gameId, $linkedStorylet, $runtimeState, $resolvedBindings ); $occurrence = wayfarerRuntimeIncrementStoryletOccurrence( $runtimeState, $nextStoryletId ); $chain[] = [ 'storylet_id' => $nextStoryletId, 'bindings' => $resolvedBindings, 'effects' => $effects, 'occurrence' => $occurrence, ]; $nextTransitionId = wayfarerRuntimeConsumeStoryletTransition( $runtimeState ); if ($nextTransitionId <= 0) { return [ 'storylet' => $linkedStorylet, 'bindings' => $resolvedBindings, 'effects' => $effects, 'occurrence' => $occurrence, 'chain' => $chain, ]; } $nextStoryletId = $nextTransitionId; } throw new RuntimeException( 'Storylet Continue To did not resolve to a valid Storylet.' ); } function wayfarerRuntimeApplyStoryletEffects( PDO $pdo, int $gameId, array $storylet, array &$runtimeState, array $bindings = [] ): array { $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); $effects = $config['effects'] ?? []; if (!is_array($effects)) { $effects = []; } $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $traitDefinitions = wayfarerRuntimeStoryletTraitDefinitions( $pdo, $gameId, $runtimeState ); wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $regionId ); $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { throw new RuntimeException( 'Malformed Storylet Effect.' ); } $materialized = wayfarerRuntimeMaterializeStoryletBindingEffect( $effect, $bindings ); if ( ($materialized['error'] ?? '') !== '' ) { throw new RuntimeException( 'Storylet Binding Effect failed: ' . (string)$materialized['error'] ); } $runtimeEffect = $materialized['effect'] ?? $effect; if ( (string)( $runtimeEffect['target_scope'] ?? '' ) === 'storylet' && (string)( $runtimeEffect['target_type'] ?? '' ) === 'transition' ) { $selection = wayfarerRuntimeSelectEffectBranch( $runtimeState, $runtimeEffect, $regionId ); if ( ($selection['error'] ?? '') !== '' ) { throw new RuntimeException( 'Storylet Continue To Effect failed: ' . (string)$selection['error'] ); } $matched = !empty( $selection['matched'] ); $branch = is_array( $selection['branch'] ?? null ) ? $selection['branch'] : []; $operation = strtolower( trim( (string)( $branch['operation'] ?? 'none' ) ) ); $targetStoryletId = (int)( $runtimeEffect['target_id'] ?? 0 ); $applied = $matched && $operation === 'continue'; if ($applied) { if ($targetStoryletId <= 0) { throw new RuntimeException( 'Storylet Continue To Effect has an invalid destination.' ); } $existingTransition = (int)( $runtimeState['runtime']['pending_storylet_transition'] ?? 0 ); if ( $existingTransition > 0 && $existingTransition !== $targetStoryletId ) { throw new RuntimeException( 'More than one Storylet Continue To Effect matched.' ); } $runtimeState['runtime']['pending_storylet_transition'] = $targetStoryletId; } $reports[] = [ 'scope' => 'storylet', 'type' => 'transition', 'target_id' => (string)$targetStoryletId, 'operation' => $operation, 'applied' => $applied, 'matched' => $matched, 'branch_index' => $selection['branch_index'] ?? null, 'error' => '', ]; continue; } $report = wayfarerApplyRuntimeOneTimeEffect( $runtimeState, $runtimeEffect, $regionId, $traitDefinitions ); if (!empty($report['error'])) { throw new RuntimeException( 'Storylet Effect failed: ' . (string)$report['error'] ); } $reports[] = $report; } /* WAYFARER_STORYLET_EFFECT_FEEDBACK_V70 */ if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_storylet_effects'] = [ 'storylet_id' => (int)( $storylet['id'] ?? 0 ), 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), 'reports' => $reports, 'changes' => wayfarerRuntimeBuildEffectChangeEvents( $pdo, $gameId, $runtimeState, $reports ), ]; return $reports; } function wayfarerRuntimeIncrementStoryletOccurrence( array &$runtimeState, int $storyletId ): int { if ( !isset($runtimeState['storylets']) || !is_array($runtimeState['storylets']) ) { $runtimeState['storylets'] = []; } $key = (string)$storyletId; if ( !isset($runtimeState['storylets'][$key]) || !is_array($runtimeState['storylets'][$key]) ) { $runtimeState['storylets'][$key] = []; } $count = wayfarerRuntimeStoryletOccurrenceCount( $runtimeState, $storyletId ) + 1; $runtimeState['storylets'][$key]['occurrences'] = $count; $runtimeState['storylets'][$key]['last_entered_tick'] = (int)( $runtimeState['runtime']['tick'] ?? 0 ); return $count; } function wayfarerRuntimeEnterStorylet( PDO $pdo, int $gameUserId, int $storyletId, string $entryMode = 'action', int $expectedParentStoryletId = 0 ): array { $entryMode = strtolower( trim($entryMode) ); if ( $entryMode !== 'action' && $entryMode !== 'branch' ) { throw new RuntimeException( 'Invalid Storylet entry mode.' ); } try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $gameId = (int)$row['game_id']; $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } if ( isset($state['runtime']['ending']) && is_array($state['runtime']['ending']) ) { throw new RuntimeException( 'This game has ended.' ); } $activePresentation = $state['runtime']['presentation'] ?? null; $storylet = wayfarerRuntimeLoadStorylet( $pdo, $gameId, $storyletId ); if (!$storylet) { throw new RuntimeException( 'Storylet not found.' ); } if ($entryMode === 'action') { if (is_array($activePresentation)) { throw new RuntimeException( 'Continue the current presentation before choosing an Action.' ); } if ( !empty($storylet['parent_storylet_id']) || strtolower( trim( (string)( $storylet['trigger_type'] ?? '' ) ) ) !== 'manual' ) { throw new RuntimeException( 'That Storylet is not an available root Manual Action.' ); } } else { if ( !is_array($activePresentation) || (string)( $activePresentation['type'] ?? '' ) !== 'storylet' || (int)( $activePresentation['id'] ?? 0 ) <= 0 ) { throw new RuntimeException( 'There is no active Storylet Branch to choose from.' ); } $parentStoryletId = (int)( $activePresentation['id'] ?? 0 ); if ( $expectedParentStoryletId > 0 && $parentStoryletId !== $expectedParentStoryletId ) { throw new RuntimeException( 'The active Storylet changed before the Branch was selected.' ); } if ( (int)( $storylet['parent_storylet_id'] ?? 0 ) !== $parentStoryletId ) { throw new RuntimeException( 'That Storylet is not a Branch of the active Storylet.' ); } } wayfarerRuntimeEnsureStoryletTargetState( $pdo, $gameId, $state ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $state ) || !wayfarerRuntimeStoryletBindingsAvailable( $pdo, $gameId, $storylet, $state ) ) { throw new RuntimeException( 'That Storylet is no longer eligible.' ); } $config = $storylet['_runtime_config'] ?? []; $tickCost = max( 0, (int)( $config['tick_cost'] ?? 0 ) ); /* * V59 proves interactive Storylets before refactoring the normal tick * pipeline into a reusable "consume N ticks" helper. Reject non-zero * cost rather than silently ignoring authored time passage. */ if ($tickCost > 0) { throw new RuntimeException( 'Storylet Tick Cost runtime is not implemented yet. Set Tick Cost to 0 for V59.' ); } /* WAYFARER_STORYLET_BINDING_RUNTIME_V79 */ $bindingResult = wayfarerRuntimeResolveStoryletBindings( $pdo, $gameId, $storylet, $state ); if ( ($bindingResult['error'] ?? '') !== '' ) { throw new RuntimeException( (string)$bindingResult['error'] ); } $resolvedBindings = is_array( $bindingResult['bindings'] ?? null ) ? $bindingResult['bindings'] : []; $effects = wayfarerRuntimeApplyStoryletEffects( $pdo, $gameId, $storylet, $state, $resolvedBindings ); $occurrence = wayfarerRuntimeIncrementStoryletOccurrence( $state, $storyletId ); if ($entryMode === 'branch') { $state['runtime']['last_presentation'] = $activePresentation; } $presentationStorylet = $storylet; $presentationBindings = $resolvedBindings; $presentationOccurrence = $occurrence; $presentationTrigger = $entryMode; $transitionId = wayfarerRuntimeConsumeStoryletTransition( $state ); if ($transitionId > 0) { $linkedResult = wayfarerRuntimeEnterLinkedRootStorylet( $pdo, $gameId, $transitionId, $state, [ (string)$storyletId => true, ] ); $presentationStorylet = $linkedResult['storylet']; $presentationBindings = $linkedResult['bindings']; $presentationOccurrence = $linkedResult['occurrence']; $presentationTrigger = 'transition'; $state['runtime']['last_storylet_transition'] = [ 'from_storylet_id' => $storyletId, 'to_storylet_id' => (int)( $presentationStorylet['id'] ?? 0 ), 'chain' => $linkedResult['chain'], 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } else { unset( $state['runtime']['last_storylet_transition'] ); } $presentationStoryletId = (int)( $presentationStorylet['id'] ?? 0 ); $state['runtime']['presentation'] = [ 'type' => 'storylet', 'id' => $presentationStoryletId, 'phase' => 'presentation', 'trigger' => $presentationTrigger, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), 'bindings' => $presentationBindings, ]; $state['runtime']['last_storylet_entry'] = [ 'storylet_id' => $presentationStoryletId, 'parent_storylet_id' => (int)( $presentationStorylet['parent_storylet_id'] ?? 0 ), 'entry_mode' => $presentationTrigger, 'occurrence' => $presentationOccurrence, 'bindings' => $presentationBindings, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; $ending = wayfarerRuntimeEvaluateGameEndings( $pdo, $gameId, $state ); $encoded = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encoded, $gameUserId, ]); $pdo->commit(); $presentation = $state['runtime']['presentation'] ?? null; return [ 'game_user_id' => (int)$row['id'], 'game_id' => $gameId, 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, $gameId, $state ), 'presentation' => is_array($presentation) ? $presentation : null, 'ending' => $ending, 'effects' => $effects, 'occurrence' => $presentationOccurrence, 'actions' => $ending === null && !is_array($presentation) ? wayfarerRuntimeEligibleRootManualStorylets( $pdo, $gameId, $state ) : [], ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: _wayfarer_backups/v89-20260929-213441/admin/builder.php ================================================================================ prepare(" SELECT id, name, config FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; /* WAYFARER_STORYLET_BUILDER_V56 * A + New Branch Storylet link can preselect its parent and choose a sensible * sibling order without making parent_storylet_id part of the generic request. */ if ( $type === 'storylets' && !$id && !empty($_GET['parent_storylet_id']) ) { $requestedParentId = (int)$_GET['parent_storylet_id']; $parentStmt = $pdo->prepare( " SELECT id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $parentStmt->execute([ $requestedParentId, $gameId, ]); if ($parentStmt->fetchColumn()) { $record['parent_storylet_id'] = $requestedParentId; $orderStmt = $pdo->prepare( " SELECT COALESCE(MAX(branch_sort_order), 0) FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? " ); $orderStmt->execute([ $gameId, $requestedParentId, ]); $record['branch_sort_order'] = (int)$orderStmt->fetchColumn() + 10; } } // ================================================================ // SAVE / DELETE / DUPLICATE // WAYFARER_STORYLET_DUPLICATE_V83 // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'duplicate') { $duplicateId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; if ( ($definition['table'] ?? '') !== 'wf_storylets' ) { $error = 'Only Storylets can be duplicated.'; } else { $duplicateResult = duplicateStoryletRecord( $pdo, $gameId, $duplicateId ); $error = $duplicateResult['error']; if ($duplicateResult['duplicated']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) . '&id=' . (int)$duplicateResult['id'] . '&tab=basics' . '&duplicated=1' ); exit; } } } elseif ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { /* * Media Library associations are applied only after the authored * object has saved successfully and therefore has a stable ID. */ $savedRecordId = (int)$saveResult['id']; $mediaResult = applyBuilderMediaSelections( $pdo, $gameId, $definition['table'], $savedRecordId, $_POST, $_FILES ); if (!$mediaResult['saved']) { /* * The authored object itself has already saved. Keep the * editor on that record and report the media failure * accurately rather than pretending the object save failed. */ $id = $savedRecordId; $record['id'] = $savedRecordId; $error = 'Record saved, but media was not changed: ' . $mediaResult['error']; } else { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) . '&id=' . (int)$saveResult['id'] . '&tab=' . rawurlencode( (string)($_POST['active_tab'] ?? 'basics') ) ); exit; } } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder

================================================================================ FILE: _wayfarer_backups/v89-20260929-213441/includes/builder/status-lifecycle.php ================================================================================ Select root Storylet...'; foreach ($storylets as $storylet) { $id = (int)($storylet['id'] ?? 0); if ($id <= 0) { continue; } $name = trim((string)($storylet['name'] ?? ('Storylet ' . $id))); $status = strtolower(trim((string)($storylet['status'] ?? 'draft'))); $trigger = strtolower(trim((string)($storylet['trigger_type'] ?? 'manual'))); $suffix = []; if ($status !== 'active') { $suffix[] = ucfirst($status); } if ($trigger === 'event') { $suffix[] = 'Event'; } $label = $name; if ($suffix) { $label .= ' [' . implode(', ', $suffix) . ']'; } echo ''; } }; $statusLifecycleRenderResultOptions = static function ( array $stages, string $selected ): void { echo ''; echo ''; foreach ($stages as $stage) { if (!is_array($stage)) { continue; } $key = trim((string)($stage['key'] ?? '')); $name = trim((string)($stage['name'] ?? $key)); if ($key === '') { continue; } $value = 'stage:' . $key; echo ''; } }; ?>

Status Progression

Manual Statuses stay active until an Effect changes them. Timed Stages advance independently for every affected character. The first Stage is entered when the Status becomes active.

>

Stages

Each Stage rolls its duration once on entry. When its timer expires, exactly one Outcome is chosen from the authored percentages.
$stage): ?>
Stage
Outcomes Chances for this Stage must total exactly 100%.
$outcome): ?>

When an Outcome opens a Storylet, Wayfarer supplies the affected character as the reserved subject binding. Use {{subject}} or {{subject.name}} in Storylet text. If the Storylet defines a normal Binding with Key subject, Status-triggered entry reuses the affected character instead of rolling a new one.

================================================================================ FILE: _wayfarer_backups/v89-20260929-213441/includes/gameplay/status-lifecycle.php ================================================================================ prepare( " SELECT id, name, trigger_type, status FROM wf_storylets WHERE game_id = ? AND parent_storylet_id IS NULL ORDER BY name, id " ); $stmt->execute([$gameId]); $rows = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $rows[$id] = [ 'id' => $id, 'name' => trim((string)($row['name'] ?? ('Storylet ' . $id))), 'trigger_type' => strtolower(trim((string)($row['trigger_type'] ?? 'manual'))), 'status' => strtolower(trim((string)($row['status'] ?? 'draft'))), ]; } return $rows; } function gameplayBuildStatusLifecycleFromPost( PDO $pdo, int $gameId, $postedLifecycle ): array { $postedLifecycle = is_array($postedLifecycle) ? $postedLifecycle : []; $mode = strtolower(trim((string)($postedLifecycle['mode'] ?? 'manual'))); if (!in_array($mode, ['manual', 'progression'], true)) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Progression Mode must be Manual or Timed Stages.', ]; } if ($mode === 'manual') { return [ 'lifecycle' => null, 'editor' => [ 'mode' => 'manual', 'stages' => [], ], 'error' => '', ]; } $postedStages = isset($postedLifecycle['stages']) && is_array($postedLifecycle['stages']) ? array_values($postedLifecycle['stages']) : []; if ($postedStages === []) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Timed Status progression needs at least one Stage.', ]; } $stageKeys = []; $stages = []; foreach ($postedStages as $stageIndex => $postedStage) { if (!is_array($postedStage)) { continue; } $stageNumber = $stageIndex + 1; $key = gameplayStatusLifecycleNormalizeKey( (string)($postedStage['key'] ?? '') ); if ( $key === '' || preg_match('/^[a-z][a-z0-9_]{0,47}$/', $key) !== 1 ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' has an invalid internal key. Remove and re-add that Stage.', ]; } if (isset($stageKeys[$key])) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage keys must be unique.', ]; } $name = trim((string)($postedStage['name'] ?? '')); if ($name === '') { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' needs a Stage Name.', ]; } $nameLength = function_exists('mb_strlen') ? mb_strlen($name, 'UTF-8') : strlen($name); if ($nameLength > 80) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' name must be 80 characters or fewer.', ]; } $durationMode = strtolower(trim((string)($postedStage['duration_mode'] ?? 'fixed'))); if (!in_array($durationMode, ['fixed', 'random'], true)) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' has an invalid Duration Mode.', ]; } if ($durationMode === 'fixed') { $value = $postedStage['duration_value'] ?? ''; if ( filter_var($value, FILTER_VALIDATE_INT) === false || (int)$value < 1 ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' Fixed Duration must be at least 1 tick.', ]; } $duration = [ 'mode' => 'fixed', 'value' => (int)$value, ]; } else { $min = $postedStage['duration_min'] ?? ''; $max = $postedStage['duration_max'] ?? ''; if ( filter_var($min, FILTER_VALIDATE_INT) === false || filter_var($max, FILTER_VALIDATE_INT) === false || (int)$min < 1 || (int)$max < 1 || (int)$min > (int)$max ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' Random Duration needs whole-number Min/Max ticks of at least 1, with Min no greater than Max.', ]; } $duration = [ 'mode' => 'random', 'min' => (int)$min, 'max' => (int)$max, ]; } $stageKeys[$key] = true; $stages[] = [ 'key' => $key, 'name' => $name, 'duration' => $duration, '_posted_outcomes' => isset($postedStage['outcomes']) && is_array($postedStage['outcomes']) ? array_values($postedStage['outcomes']) : [], ]; } if ($stages === []) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Timed Status progression needs at least one valid Stage.', ]; } $storylets = gameplayStatusLifecycleStorylets( $pdo, $gameId ); foreach ($stages as $stageIndex => &$stage) { $stageNumber = $stageIndex + 1; $postedOutcomes = $stage['_posted_outcomes']; unset($stage['_posted_outcomes']); if ($postedOutcomes === []) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' needs at least one Outcome.', ]; } $outcomes = []; $outcomeKeys = []; $chanceTotal = 0; foreach ($postedOutcomes as $outcomeIndex => $postedOutcome) { if (!is_array($postedOutcome)) { continue; } $outcomeNumber = $outcomeIndex + 1; $key = gameplayStatusLifecycleNormalizeKey( (string)($postedOutcome['key'] ?? '') ); if ( $key === '' || preg_match('/^[a-z][a-z0-9_]{0,47}$/', $key) !== 1 ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' has an invalid internal key. Remove and re-add that Outcome.', ]; } if (isset($outcomeKeys[$key])) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' has duplicate Outcome keys.', ]; } $label = trim((string)($postedOutcome['label'] ?? '')); if ($label === '') { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' needs an Outcome Name.', ]; } $chance = $postedOutcome['chance'] ?? ''; if ( filter_var($chance, FILTER_VALIDATE_INT) === false || (int)$chance < 1 || (int)$chance > 100 ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' Chance must be a whole number from 1 to 100.', ]; } $chance = (int)$chance; $chanceTotal += $chance; $result = strtolower(trim((string)($postedOutcome['result'] ?? 'inactive'))); if ( !in_array($result, ['inactive', 'stay'], true) && preg_match('/^stage:([a-z][a-z0-9_]{0,47})$/', $result, $match) !== 1 ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' has an invalid Result.', ]; } if ( strpos($result, 'stage:') === 0 && !isset($stageKeys[substr($result, 6)]) ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' points to a Stage that no longer exists.', ]; } $presentation = strtolower(trim((string)($postedOutcome['presentation'] ?? 'silent'))); if (!in_array($presentation, ['silent', 'storylet'], true)) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' has an invalid Presentation Mode.', ]; } $storyletId = 0; if ($presentation === 'storylet') { $storyletId = (int)($postedOutcome['storylet_id'] ?? 0); if ($storyletId <= 0 || !isset($storylets[$storyletId])) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' must select a root Storylet from this Game.', ]; } } $outcomeKeys[$key] = true; $outcomes[] = [ 'key' => $key, 'label' => $label, 'chance' => $chance, 'result' => $result, 'presentation' => $presentation, 'storylet_id' => $presentation === 'storylet' ? $storyletId : null, ]; } if ($outcomes === []) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' needs at least one valid Outcome.', ]; } if ($chanceTotal !== 100) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' Outcome chances must total exactly 100%. Current total: ' . $chanceTotal . '%.', ]; } $stage['outcomes'] = $outcomes; } unset($stage); $lifecycle = [ 'mode' => 'progression', 'stages' => $stages, ]; return [ 'lifecycle' => $lifecycle, 'editor' => $lifecycle, 'error' => '', ]; } ================================================================================ FILE: _wayfarer_backups/v89-20260929-213441/play/includes/statuses.php ================================================================================ 0; } return in_array( strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on', 'active'], true ); } function wayfarerRuntimeStatusDefinitions( PDO $pdo, int $gameId, bool $activeOnly = true ): array { if ($gameId <= 0) { return []; } $sql = " SELECT id, name, description, config, status FROM wf_statuses WHERE game_id = ? "; if ($activeOnly) { $sql .= " AND status = 'active'"; } $sql .= " ORDER BY name, id"; $stmt = $pdo->prepare($sql); $stmt->execute([$gameId]); $definitions = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $config = []; if (!empty($row['config'])) { $decoded = json_decode((string)$row['config'], true); if (is_array($decoded)) { $config = $decoded; } } $showToPlayer = !array_key_exists('show_to_player', $config) || wayfarerRuntimeStatusNormalizeBoolean($config['show_to_player']); $showStageToPlayer = !array_key_exists('show_stage_to_player', $config) || wayfarerRuntimeStatusNormalizeBoolean($config['show_stage_to_player']); $showTimeRemaining = array_key_exists('show_time_remaining', $config) && wayfarerRuntimeStatusNormalizeBoolean($config['show_time_remaining']); $lifecycle = isset($config['lifecycle']) && is_array($config['lifecycle']) ? $config['lifecycle'] : null; $definitions[(string)$id] = [ 'id' => $id, 'name' => trim((string)($row['name'] ?? ('Status ' . $id))), 'description' => trim((string)($row['description'] ?? '')), 'status' => strtolower(trim((string)($row['status'] ?? 'draft'))), 'show_to_player' => $showToPlayer, 'show_stage_to_player' => $showStageToPlayer, 'show_time_remaining' => $showTimeRemaining, 'lifecycle' => $lifecycle, ]; } return $definitions; } function wayfarerRuntimeStatusDefinition( PDO $pdo, int $gameId, int $statusId, bool $activeOnly = true ): ?array { if ($statusId <= 0) { return null; } $definitions = wayfarerRuntimeStatusDefinitions( $pdo, $gameId, $activeOnly ); return $definitions[(string)$statusId] ?? null; } function wayfarerRuntimeStatusScopeAddress( array $runtimeState, string $scope ): ?array { $scope = strtolower(trim($scope)); if ( preg_match('/^player:([0-9]+)$/', $scope, $match) === 1 ) { $key = (string)((int)$match[1]); if (!isset($runtimeState['players'][$key]) || !is_array($runtimeState['players'][$key])) { return null; } return [ 'bucket' => 'players', 'key' => $key, ]; } if ( preg_match('/^entity:([1-9][0-9]*)$/', $scope, $match) === 1 ) { $key = (string)((int)$match[1]); if (!isset($runtimeState['entities'][$key]) || !is_array($runtimeState['entities'][$key])) { return null; } return [ 'bucket' => 'entities', 'key' => $key, ]; } return null; } function wayfarerRuntimeStatusState( array $runtimeState, string $scope, int $statusId ): array { $address = wayfarerRuntimeStatusScopeAddress( $runtimeState, $scope ); if ($address === null || $statusId <= 0) { return [ 'found_owner' => false, 'active' => false, 'started_tick' => null, 'stage_key' => null, 'stage_started_tick' => null, 'duration_ticks' => null, 'next_check_tick' => null, ]; } $stored = $runtimeState[$address['bucket']][$address['key']]['statuses'][(string)$statusId] ?? null; if (!is_array($stored)) { return [ 'found_owner' => true, 'active' => false, 'started_tick' => null, 'stage_key' => null, 'stage_started_tick' => null, 'duration_ticks' => null, 'next_check_tick' => null, ]; } return [ 'found_owner' => true, 'active' => !empty($stored['active']), 'started_tick' => isset($stored['started_tick']) && is_numeric($stored['started_tick']) ? (int)$stored['started_tick'] : null, 'stage_key' => trim((string)($stored['stage_key'] ?? '')) !== '' ? trim((string)$stored['stage_key']) : null, 'stage_started_tick' => isset($stored['stage_started_tick']) && is_numeric($stored['stage_started_tick']) ? (int)$stored['stage_started_tick'] : null, 'duration_ticks' => isset($stored['duration_ticks']) && is_numeric($stored['duration_ticks']) ? (int)$stored['duration_ticks'] : null, 'next_check_tick' => isset($stored['next_check_tick']) && is_numeric($stored['next_check_tick']) ? (int)$stored['next_check_tick'] : null, ]; } function wayfarerRuntimeSetStatus( array &$runtimeState, string $scope, int $statusId, bool $active ): array { $address = wayfarerRuntimeStatusScopeAddress( $runtimeState, $scope ); if ($address === null || $statusId <= 0) { return [ 'applied' => false, 'before' => null, 'after' => null, 'started_tick' => null, 'error' => 'Status owner was not found in runtime state.', ]; } $bucket = $address['bucket']; $key = $address['key']; if (!isset($runtimeState[$bucket][$key]['statuses']) || !is_array($runtimeState[$bucket][$key]['statuses'])) { $runtimeState[$bucket][$key]['statuses'] = []; } $statusKey = (string)$statusId; $existing = $runtimeState[$bucket][$key]['statuses'][$statusKey] ?? []; if (!is_array($existing)) { $existing = []; } $before = !empty($existing['active']); $startedTick = isset($existing['started_tick']) && is_numeric($existing['started_tick']) ? (int)$existing['started_tick'] : null; if ($active && !$before) { $startedTick = (int)( $runtimeState['runtime']['tick'] ?? 0 ); unset( $existing['stage_key'], $existing['stage_started_tick'], $existing['duration_ticks'], $existing['next_check_tick'], $existing['last_outcome_key'], $existing['last_outcome_tick'] ); } $existing['active'] = $active; $existing['started_tick'] = $startedTick; $runtimeState[$bucket][$key]['statuses'][$statusKey] = $existing; return [ 'applied' => true, 'before' => $before ? 1 : 0, 'after' => $active ? 1 : 0, 'started_tick' => $startedTick, 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Timed lifecycle helpers | WAYFARER_STATUS_LIFECYCLE_RUNTIME_V88 |-------------------------------------------------------------------------- */ function wayfarerRuntimeStatusLifecycleStages( array $definition ): array { $lifecycle = isset($definition['lifecycle']) && is_array($definition['lifecycle']) ? $definition['lifecycle'] : []; if ( strtolower(trim((string)($lifecycle['mode'] ?? 'manual'))) !== 'progression' || !isset($lifecycle['stages']) || !is_array($lifecycle['stages']) ) { return []; } $stages = []; foreach (array_values($lifecycle['stages']) as $stage) { if (!is_array($stage)) { continue; } $key = strtolower(trim((string)($stage['key'] ?? ''))); if ($key === '') { continue; } $stage['key'] = $key; $stages[] = $stage; } return $stages; } function wayfarerRuntimeStatusLifecycleStageMap( array $definition ): array { $map = []; foreach (wayfarerRuntimeStatusLifecycleStages($definition) as $stage) { $map[(string)$stage['key']] = $stage; } return $map; } function wayfarerRuntimeStatusRollDuration( array $stage ): int { $duration = isset($stage['duration']) && is_array($stage['duration']) ? $stage['duration'] : []; $mode = strtolower(trim((string)($duration['mode'] ?? 'fixed'))); if ($mode === 'random') { $min = max(1, (int)($duration['min'] ?? 1)); $max = max($min, (int)($duration['max'] ?? $min)); return random_int($min, $max); } return max(1, (int)($duration['value'] ?? 1)); } function wayfarerRuntimeStatusInitializeLifecycle( array &$runtimeState, string $scope, int $statusId, array $definition ): array { $address = wayfarerRuntimeStatusScopeAddress($runtimeState, $scope); $stages = wayfarerRuntimeStatusLifecycleStages($definition); if ($address === null || $statusId <= 0 || $stages === []) { return [ 'initialized' => false, 'error' => '', ]; } $bucket = $address['bucket']; $key = $address['key']; $statusKey = (string)$statusId; $stored = $runtimeState[$bucket][$key]['statuses'][$statusKey] ?? null; if (!is_array($stored) || empty($stored['active'])) { return [ 'initialized' => false, 'error' => '', ]; } $stageMap = wayfarerRuntimeStatusLifecycleStageMap($definition); $stageKey = strtolower(trim((string)($stored['stage_key'] ?? ''))); if ($stageKey === '' || !isset($stageMap[$stageKey])) { $stageKey = (string)$stages[0]['key']; $stored['stage_key'] = $stageKey; $stored['stage_started_tick'] = isset($stored['started_tick']) && is_numeric($stored['started_tick']) ? (int)$stored['started_tick'] : (int)($runtimeState['runtime']['tick'] ?? 0); unset($stored['duration_ticks'], $stored['next_check_tick']); } if ( !isset($stored['stage_started_tick']) || !is_numeric($stored['stage_started_tick']) ) { $stored['stage_started_tick'] = (int)( $runtimeState['runtime']['tick'] ?? 0 ); } if ( !isset($stored['duration_ticks']) || !is_numeric($stored['duration_ticks']) || (int)$stored['duration_ticks'] < 1 ) { $stored['duration_ticks'] = wayfarerRuntimeStatusRollDuration( $stageMap[$stageKey] ); } if ( !isset($stored['next_check_tick']) || !is_numeric($stored['next_check_tick']) ) { $stored['next_check_tick'] = (int)$stored['stage_started_tick'] + (int)$stored['duration_ticks']; } $runtimeState[$bucket][$key]['statuses'][$statusKey] = $stored; return [ 'initialized' => true, 'stage_key' => $stageKey, 'stage_started_tick' => (int)$stored['stage_started_tick'], 'duration_ticks' => (int)$stored['duration_ticks'], 'next_check_tick' => (int)$stored['next_check_tick'], 'error' => '', ]; } function wayfarerRuntimeStatusEnterStage( array &$runtimeState, string $scope, int $statusId, array $definition, string $stageKey ): array { $address = wayfarerRuntimeStatusScopeAddress($runtimeState, $scope); $stageMap = wayfarerRuntimeStatusLifecycleStageMap($definition); $stageKey = strtolower(trim($stageKey)); if ( $address === null || $statusId <= 0 || $stageKey === '' || !isset($stageMap[$stageKey]) ) { return [ 'applied' => false, 'error' => 'Status Stage could not be resolved.', ]; } $bucket = $address['bucket']; $key = $address['key']; $statusKey = (string)$statusId; $stored = $runtimeState[$bucket][$key]['statuses'][$statusKey] ?? []; if (!is_array($stored)) { $stored = []; } $tick = (int)($runtimeState['runtime']['tick'] ?? 0); $duration = wayfarerRuntimeStatusRollDuration($stageMap[$stageKey]); $stored['active'] = true; if (!isset($stored['started_tick']) || !is_numeric($stored['started_tick'])) { $stored['started_tick'] = $tick; } $stored['stage_key'] = $stageKey; $stored['stage_started_tick'] = $tick; $stored['duration_ticks'] = $duration; $stored['next_check_tick'] = $tick + $duration; $runtimeState[$bucket][$key]['statuses'][$statusKey] = $stored; return [ 'applied' => true, 'stage_key' => $stageKey, 'stage_started_tick' => $tick, 'duration_ticks' => $duration, 'next_check_tick' => $tick + $duration, 'error' => '', ]; } function wayfarerRuntimeStatusChooseOutcome( array $stage ): ?array { $outcomes = isset($stage['outcomes']) && is_array($stage['outcomes']) ? array_values($stage['outcomes']) : []; if ($outcomes === []) { return null; } $roll = random_int(1, 100); $cursor = 0; foreach ($outcomes as $outcome) { if (!is_array($outcome)) { continue; } $chance = max(0, (int)($outcome['chance'] ?? 0)); $cursor += $chance; if ($roll <= $cursor) { $outcome['_runtime_roll'] = $roll; return $outcome; } } return null; } function wayfarerRuntimeQueueStatusStorylet( array &$runtimeState, array $event ): void { if (!isset($runtimeState['runtime']) || !is_array($runtimeState['runtime'])) { $runtimeState['runtime'] = []; } if ( !isset($runtimeState['runtime']['pending_status_storylets']) || !is_array($runtimeState['runtime']['pending_status_storylets']) ) { $runtimeState['runtime']['pending_status_storylets'] = []; } $runtimeState['runtime']['pending_status_storylets'][] = $event; } function wayfarerRuntimePromotePendingStatusStorylet( PDO $pdo, int $gameId, array &$runtimeState ): ?array { if ( isset($runtimeState['runtime']['presentation']) && is_array($runtimeState['runtime']['presentation']) ) { return null; } if ( !isset($runtimeState['runtime']['pending_status_storylets']) || !is_array($runtimeState['runtime']['pending_status_storylets']) || $runtimeState['runtime']['pending_status_storylets'] === [] ) { return null; } while ($runtimeState['runtime']['pending_status_storylets'] !== []) { $event = array_shift( $runtimeState['runtime']['pending_status_storylets'] ); if (!is_array($event)) { continue; } $storyletId = (int)($event['storylet_id'] ?? 0); $subjectScope = strtolower(trim((string)($event['subject_scope'] ?? ''))); if ($storyletId <= 0 || $subjectScope === '') { continue; } try { $storylet = wayfarerRuntimeLoadStorylet( $pdo, $gameId, $storyletId ); if (!$storylet) { throw new RuntimeException( 'Status Outcome Storylet could not be loaded.' ); } $subjectResult = wayfarerRuntimeStoryletPresetCharacterBinding( $pdo, $gameId, $storylet, $runtimeState, $subjectScope, 'subject', 'Subject' ); if (($subjectResult['error'] ?? '') !== '') { throw new RuntimeException( (string)$subjectResult['error'] ); } $binding = $subjectResult['binding'] ?? null; if (!is_array($binding)) { throw new RuntimeException( 'Status Outcome Storylet subject could not be prepared.' ); } $result = wayfarerRuntimeEnterTriggeredStoryletInState( $pdo, $gameId, $storyletId, $runtimeState, [ 'subject' => $binding, ], 'status' ); $runtimeState['runtime']['last_status_storylet'] = [ 'storylet_id' => $storyletId, 'subject_scope' => $subjectScope, 'status_id' => (int)($event['status_id'] ?? 0), 'outcome_key' => (string)($event['outcome_key'] ?? ''), 'tick' => (int)($runtimeState['runtime']['tick'] ?? 0), ]; if ($runtimeState['runtime']['pending_status_storylets'] === []) { unset($runtimeState['runtime']['pending_status_storylets']); } return $result; } catch (Throwable $exception) { $runtimeState['runtime']['last_status_storylet_error'] = [ 'storylet_id' => $storyletId, 'subject_scope' => $subjectScope, 'message' => $exception->getMessage(), 'tick' => (int)($runtimeState['runtime']['tick'] ?? 0), ]; } } unset($runtimeState['runtime']['pending_status_storylets']); return null; } function wayfarerRuntimeProcessStatusProgression( PDO $pdo, int $gameId, array &$runtimeState ): array { $definitions = wayfarerRuntimeStatusDefinitions( $pdo, $gameId, true ); if ($definitions === []) { return []; } $tick = (int)($runtimeState['runtime']['tick'] ?? 0); $reports = []; $owners = []; foreach ( isset($runtimeState['players']) && is_array($runtimeState['players']) ? $runtimeState['players'] : [] as $playerKey => $playerState ) { if (is_array($playerState)) { $owners[] = 'player:' . (string)$playerKey; } } foreach ( isset($runtimeState['entities']) && is_array($runtimeState['entities']) ? $runtimeState['entities'] : [] as $entityKey => $entityState ) { if (is_array($entityState)) { $owners[] = 'entity:' . (string)$entityKey; } } foreach ($owners as $scope) { $address = wayfarerRuntimeStatusScopeAddress($runtimeState, $scope); if ($address === null) { continue; } $storedStatuses = $runtimeState[$address['bucket']][$address['key']]['statuses'] ?? []; if (!is_array($storedStatuses) || $storedStatuses === []) { continue; } foreach (array_keys($storedStatuses) as $statusKey) { $statusId = (int)$statusKey; $definition = $definitions[(string)$statusId] ?? null; if (!is_array($definition)) { continue; } if (wayfarerRuntimeStatusLifecycleStages($definition) === []) { continue; } $init = wayfarerRuntimeStatusInitializeLifecycle( $runtimeState, $scope, $statusId, $definition ); if (empty($init['initialized'])) { continue; } $state = wayfarerRuntimeStatusState( $runtimeState, $scope, $statusId ); if ( empty($state['active']) || $state['next_check_tick'] === null || (int)$state['next_check_tick'] > $tick ) { continue; } $stageMap = wayfarerRuntimeStatusLifecycleStageMap($definition); $stageKey = (string)($state['stage_key'] ?? ''); $stage = $stageMap[$stageKey] ?? null; if (!is_array($stage)) { continue; } $outcome = wayfarerRuntimeStatusChooseOutcome($stage); if (!is_array($outcome)) { continue; } $result = strtolower(trim((string)($outcome['result'] ?? 'inactive'))); $resultError = ''; if ($result === 'inactive') { $statusResult = wayfarerRuntimeSetStatus( $runtimeState, $scope, $statusId, false ); $resultError = (string)($statusResult['error'] ?? ''); } elseif ($result === 'stay') { $stageResult = wayfarerRuntimeStatusEnterStage( $runtimeState, $scope, $statusId, $definition, $stageKey ); $resultError = (string)($stageResult['error'] ?? ''); } elseif (strpos($result, 'stage:') === 0) { $stageResult = wayfarerRuntimeStatusEnterStage( $runtimeState, $scope, $statusId, $definition, substr($result, 6) ); $resultError = (string)($stageResult['error'] ?? ''); } else { $resultError = 'Status Outcome Result is invalid.'; } if ($resultError !== '') { throw new RuntimeException( 'Status progression failed for ' . (string)($definition['name'] ?? ('Status ' . $statusId)) . ': ' . $resultError ); } $afterAddress = wayfarerRuntimeStatusScopeAddress($runtimeState, $scope); if ($afterAddress !== null) { $afterStored =& $runtimeState[$afterAddress['bucket']][$afterAddress['key']]['statuses'][(string)$statusId]; if (is_array($afterStored)) { $afterStored['last_outcome_key'] = (string)($outcome['key'] ?? ''); $afterStored['last_outcome_tick'] = $tick; } unset($afterStored); } $presentation = strtolower(trim((string)($outcome['presentation'] ?? 'silent'))); $storyletId = (int)($outcome['storylet_id'] ?? 0); if ($presentation === 'storylet' && $storyletId > 0) { wayfarerRuntimeQueueStatusStorylet( $runtimeState, [ 'storylet_id' => $storyletId, 'subject_scope' => $scope, 'status_id' => $statusId, 'status_name' => (string)($definition['name'] ?? ''), 'stage_key' => $stageKey, 'stage_name' => (string)($stage['name'] ?? $stageKey), 'outcome_key' => (string)($outcome['key'] ?? ''), 'outcome_label' => (string)($outcome['label'] ?? ''), 'tick' => $tick, ] ); } $reports[] = [ 'scope' => $scope, 'status_id' => $statusId, 'status_name' => (string)($definition['name'] ?? ''), 'stage_key' => $stageKey, 'stage_name' => (string)($stage['name'] ?? $stageKey), 'outcome_key' => (string)($outcome['key'] ?? ''), 'outcome_label' => (string)($outcome['label'] ?? ''), 'result' => $result, 'roll' => (int)($outcome['_runtime_roll'] ?? 0), 'presentation' => $presentation, 'storylet_id' => $storyletId, 'tick' => $tick, ]; } } if ($reports !== []) { if (!isset($runtimeState['runtime']) || !is_array($runtimeState['runtime'])) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_status_progression'] = [ 'tick' => $tick, 'reports' => $reports, ]; } return $reports; } function wayfarerRuntimeVisibleStatusesForScope( PDO $pdo, int $gameId, array $runtimeState, string $scope ): array { $address = wayfarerRuntimeStatusScopeAddress( $runtimeState, $scope ); if ($address === null) { return []; } $definitions = wayfarerRuntimeStatusDefinitions( $pdo, $gameId, true ); if ($definitions === []) { return []; } $storedStatuses = $runtimeState[$address['bucket']][$address['key']]['statuses'] ?? []; if (!is_array($storedStatuses)) { return []; } $visible = []; $currentTick = (int)($runtimeState['runtime']['tick'] ?? 0); foreach ($definitions as $statusKey => $definition) { if (empty($definition['show_to_player'])) { continue; } $stored = $storedStatuses[$statusKey] ?? null; if (!is_array($stored) || empty($stored['active'])) { continue; } $stageName = ''; $remainingTicks = null; if (wayfarerRuntimeStatusLifecycleStages($definition) !== []) { $stageMap = wayfarerRuntimeStatusLifecycleStageMap($definition); $stageKey = strtolower(trim((string)($stored['stage_key'] ?? ''))); if ($stageKey === '') { $stages = wayfarerRuntimeStatusLifecycleStages($definition); $stageKey = (string)($stages[0]['key'] ?? ''); } if ( !empty($definition['show_stage_to_player']) && isset($stageMap[$stageKey]) ) { $stageName = trim((string)($stageMap[$stageKey]['name'] ?? $stageKey)); } if ( !empty($definition['show_time_remaining']) && isset($stored['next_check_tick']) && is_numeric($stored['next_check_tick']) ) { $remainingTicks = max( 0, (int)$stored['next_check_tick'] - $currentTick ); } } $visible[] = [ 'id' => (int)$definition['id'], 'name' => (string)$definition['name'], 'description' => (string)$definition['description'], 'started_tick' => isset($stored['started_tick']) && is_numeric($stored['started_tick']) ? (int)$stored['started_tick'] : null, 'stage_name' => $stageName, 'remaining_ticks' => $remainingTicks, ]; } return $visible; } ================================================================================ FILE: _wayfarer_backups/v90-20260929-220226/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects, array $storyletBindings = [], int $currentStoryletId = 0 ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $allowedTargets = array_replace( gameplayRuntimeStateTargets( $pdo, $gameId ), gameplayStoryletBindingEffectTargets( $pdo, $gameId, $storyletBindings ), gameplayStoryletNavigationEffectTargets( $pdo, $gameId, $currentStoryletId ) ); $result = gameplayBuildEffectsFromPost( $allowedTargets, $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); if (($result['error'] ?? '') !== '') { return $result; } $transitionCount = 0; foreach ( $result['effects'] ?? [] as $effect ) { if ( is_array($effect) && (string)( $effect['target_scope'] ?? '' ) === 'storylet' && (string)( $effect['target_type'] ?? '' ) === 'transition' ) { $transitionCount++; } } if ($transitionCount > 1) { return [ 'effects' => [], 'error' => 'A Storylet may have only one Storylet → Continue To Effect.', ]; } return $result; } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /* * WAYFARER_LINEAR_TRAVEL_SAVE_V72 * * Dedicated Linear Travel is deliberately opt-in. A null travel result means * the Region should continue using the legacy Position Tick Effect path. */ function buildRegionTravelFromPost( array $record, array $postedTravel ): array { $navigationType = strtolower( trim( (string)( $record['navigation_type'] ?? '' ) ) ); if ($navigationType !== 'linear') { return [ 'travel' => null, 'error' => '', ]; } if (empty($postedTravel['enabled'])) { return [ 'travel' => null, 'error' => '', ]; } $baseDistance = $postedTravel['base_distance'] ?? ''; if ( !is_numeric($baseDistance) || (float)$baseDistance <= 0 ) { return [ 'travel' => null, 'error' => 'Base Distance Per Tick must be greater than zero.', ]; } $baseDistance = (float)$baseDistance; if (floor($baseDistance) === $baseDistance) { $baseDistance = (int)$baseDistance; } return [ 'travel' => [ 'enabled' => true, 'base_distance' => $baseDistance, 'allow_turnaround' => !empty( $postedTravel[ 'allow_turnaround' ] ), ], 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Storylet duplication | WAYFARER_STORYLET_DUPLICATE_V83 |-------------------------------------------------------------------------- | | Duplicate the currently saved Storylet as a Draft. The duplicate copies | the selected Storylet record and its media links, but intentionally does | not recursively copy child Branch Storylets. | */ function duplicateStoryletRecord( PDO $pdo, int $gameId, int $storyletId ): array { if ($gameId <= 0 || $storyletId <= 0) { return [ 'duplicated' => false, 'id' => 0, 'error' => 'A valid Storylet is required to duplicate.', ]; } try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, uuid, game_id, name, storylet_key, description, conditions, effects, max_occurrences, trigger_type, status, config, parent_storylet_id, choice_label, choice_sort_order, branch_label, branch_sort_order FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $storyletId, $gameId, ]); $source = $stmt->fetch(PDO::FETCH_ASSOC); if (!$source) { throw new RuntimeException( 'The Storylet to duplicate could not be found.' ); } $sourceName = trim( (string)( $source['name'] ?? 'Storylet' ) ); if ($sourceName === '') { $sourceName = 'Storylet'; } $baseName = preg_replace( '/\s+-\s+COPY(?:\s+\d+)?$/i', '', $sourceName ); if (!is_string($baseName) || trim($baseName) === '') { $baseName = $sourceName; } $baseName = trim($baseName); /* Leave room for " - COPY 9999" within the 200-character Name. */ $baseName = function_exists('mb_substr') ? mb_substr($baseName, 0, 185, 'UTF-8') : substr($baseName, 0, 185); $copyName = $baseName . ' - COPY'; $copyNumber = 2; $nameCheck = $pdo->prepare( " SELECT 1 FROM wf_storylets WHERE game_id = ? AND name = ? LIMIT 1 " ); while (true) { $nameCheck->execute([ $gameId, $copyName, ]); if (!$nameCheck->fetchColumn()) { break; } $copyName = $baseName . ' - COPY ' . $copyNumber; $copyNumber++; if ($copyNumber > 9999) { throw new RuntimeException( 'Could not generate a unique Storylet copy name.' ); } } $sourceKey = trim( (string)( $source['storylet_key'] ?? '' ) ); if ($sourceKey === '') { $sourceKey = 'storylet-' . $storyletId; } $copyKey = substr( $sourceKey, 0, 80 ) . '-copy-' . bin2hex( random_bytes(6) ); $copyUuid = bin2hex( random_bytes(16) ); $parentId = (int)( $source['parent_storylet_id'] ?? 0 ); $branchSortOrder = (int)( $source['branch_sort_order'] ?? 0 ); $choiceSortOrder = (int)( $source['choice_sort_order'] ?? 0 ); $branchLabel = (string)( $source['branch_label'] ?? '' ); $choiceLabel = (string)( $source['choice_label'] ?? '' ); if ($parentId > 0) { $orderStmt = $pdo->prepare( " SELECT COALESCE(MAX(branch_sort_order), 0) FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? " ); $orderStmt->execute([ $gameId, $parentId, ]); $branchSortOrder = (int)$orderStmt->fetchColumn() + 10; $choiceSortOrder = $branchSortOrder; if (trim($branchLabel) !== '') { $branchLabel = rtrim($branchLabel) . ' - COPY'; } if (trim($choiceLabel) !== '') { $choiceLabel = rtrim($choiceLabel) . ' - COPY'; } } $insert = $pdo->prepare( " INSERT INTO wf_storylets ( uuid, game_id, name, storylet_key, description, conditions, effects, max_occurrences, trigger_type, status, config, parent_storylet_id, choice_label, choice_sort_order, branch_label, branch_sort_order ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?) " ); $insert->execute([ $copyUuid, $gameId, $copyName, $copyKey, $source['description'] ?? null, $source['conditions'] ?? null, $source['effects'] ?? null, $source['max_occurrences'] ?? null, $source['trigger_type'] ?? 'manual', $source['config'] ?? null, $parentId > 0 ? $parentId : null, $choiceLabel !== '' ? $choiceLabel : null, $choiceSortOrder, $branchLabel, $branchSortOrder, ]); $copyId = (int)$pdo->lastInsertId(); if ($copyId <= 0) { throw new RuntimeException( 'The Storylet copy could not be created.' ); } /* Reuse the same game-level media assets; do not duplicate files. */ $media = $pdo->prepare( " INSERT INTO wf_media_links ( media_id, object_type, object_id, slot ) SELECT media_id, object_type, ?, slot FROM wf_media_links WHERE object_type = 'wf_storylets' AND object_id = ? " ); $media->execute([ $copyId, $storyletId, ]); $pdo->commit(); return [ 'duplicated' => true, 'id' => $copyId, 'error' => '', ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } return [ 'duplicated' => false, 'id' => 0, 'error' => $exception->getMessage(), ]; } } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_LINEAR_TRAVEL_SAVE_V72 */ $regionTravel = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionTravelResult = buildRegionTravelFromPost( $record, isset($post['travel']) && is_array($post['travel']) ? $post['travel'] : [] ); $regionTravel = $regionTravelResult['travel'] ?? null; if ( ($regionTravelResult['error'] ?? '') !== '' ) { $error = (string)$regionTravelResult['error']; } } /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STATUS_LIFECYCLE_SAVE_V88 */ $statusLifecycle = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_statuses' ) { $statusLifecycleResult = gameplayBuildStatusLifecycleFromPost( $pdo, $gameId, $post['status_lifecycle'] ?? [] ); $statusLifecycle = $statusLifecycleResult['lifecycle'] ?? null; $record['_status_lifecycle_editor'] = is_array( $statusLifecycleResult['editor'] ?? null ) ? $statusLifecycleResult['editor'] : []; if ( ($statusLifecycleResult['error'] ?? '') !== '' ) { $error = (string)$statusLifecycleResult['error']; } } /* WAYFARER_STORYLET_PARSE_V56 */ /* WAYFARER_STORYLET_BINDINGS_V79 */ $storyletBindings = []; $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $bindingResult = gameplayBuildStoryletBindingsFromPost( isset($post['bindings']) && is_array($post['bindings']) ? array_values($post['bindings']) : [] ); $storyletBindings = $bindingResult['bindings']; if ($bindingResult['error'] !== '') { $error = $bindingResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [], $storyletBindings, $postedId ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_LINEAR_TRAVEL_SAVE_V72 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTravel !== null) { $configToSave['travel'] = $regionTravel; } else { unset( $configToSave['travel'] ); } } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STATUS_LIFECYCLE_CONFIG_V88 */ if ( ($definition['table'] ?? '') === 'wf_statuses' ) { if (is_array($statusLifecycle)) { $configToSave['lifecycle'] = $statusLifecycle; } else { unset($configToSave['lifecycle']); } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { /* WAYFARER_STORYLET_BINDINGS_V79 */ if ($storyletBindings) { $configToSave['bindings'] = array_values($storyletBindings); } else { unset($configToSave['bindings']); } if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: _wayfarer_backups/v91-20260929-222551/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects, array $storyletBindings = [], int $currentStoryletId = 0 ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $allowedTargets = array_replace( gameplayRuntimeStateTargets( $pdo, $gameId ), gameplayStoryletBindingEffectTargets( $pdo, $gameId, $storyletBindings ), gameplayStoryletNavigationEffectTargets( $pdo, $gameId, $currentStoryletId ) ); $result = gameplayBuildEffectsFromPost( $allowedTargets, $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); if (($result['error'] ?? '') !== '') { return $result; } $transitionCount = 0; foreach ( $result['effects'] ?? [] as $effect ) { if ( is_array($effect) && (string)( $effect['target_scope'] ?? '' ) === 'storylet' && (string)( $effect['target_type'] ?? '' ) === 'transition' ) { $transitionCount++; } } if ($transitionCount > 1) { return [ 'effects' => [], 'error' => 'A Storylet may have only one Storylet → Continue To Effect.', ]; } return $result; } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /* * WAYFARER_LINEAR_TRAVEL_SAVE_V72 * * Dedicated Linear Travel is deliberately opt-in. A null travel result means * the Region should continue using the legacy Position Tick Effect path. */ function buildRegionTravelFromPost( array $record, array $postedTravel ): array { $navigationType = strtolower( trim( (string)( $record['navigation_type'] ?? '' ) ) ); if ($navigationType !== 'linear') { return [ 'travel' => null, 'error' => '', ]; } if (empty($postedTravel['enabled'])) { return [ 'travel' => null, 'error' => '', ]; } $baseDistance = $postedTravel['base_distance'] ?? ''; if ( !is_numeric($baseDistance) || (float)$baseDistance <= 0 ) { return [ 'travel' => null, 'error' => 'Base Distance Per Tick must be greater than zero.', ]; } $baseDistance = (float)$baseDistance; if (floor($baseDistance) === $baseDistance) { $baseDistance = (int)$baseDistance; } return [ 'travel' => [ 'enabled' => true, 'base_distance' => $baseDistance, 'allow_turnaround' => !empty( $postedTravel[ 'allow_turnaround' ] ), ], 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Storylet duplication | WAYFARER_STORYLET_DUPLICATE_V83 |-------------------------------------------------------------------------- | | Duplicate the currently saved Storylet as a Draft. The duplicate copies | the selected Storylet record and its media links, but intentionally does | not recursively copy child Branch Storylets. | */ function duplicateStoryletRecord( PDO $pdo, int $gameId, int $storyletId ): array { if ($gameId <= 0 || $storyletId <= 0) { return [ 'duplicated' => false, 'id' => 0, 'error' => 'A valid Storylet is required to duplicate.', ]; } try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, uuid, game_id, name, storylet_key, description, conditions, effects, max_occurrences, trigger_type, status, config, parent_storylet_id, choice_label, choice_sort_order, branch_label, branch_sort_order FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $storyletId, $gameId, ]); $source = $stmt->fetch(PDO::FETCH_ASSOC); if (!$source) { throw new RuntimeException( 'The Storylet to duplicate could not be found.' ); } $sourceName = trim( (string)( $source['name'] ?? 'Storylet' ) ); if ($sourceName === '') { $sourceName = 'Storylet'; } $baseName = preg_replace( '/\s+-\s+COPY(?:\s+\d+)?$/i', '', $sourceName ); if (!is_string($baseName) || trim($baseName) === '') { $baseName = $sourceName; } $baseName = trim($baseName); /* Leave room for " - COPY 9999" within the 200-character Name. */ $baseName = function_exists('mb_substr') ? mb_substr($baseName, 0, 185, 'UTF-8') : substr($baseName, 0, 185); $copyName = $baseName . ' - COPY'; $copyNumber = 2; $nameCheck = $pdo->prepare( " SELECT 1 FROM wf_storylets WHERE game_id = ? AND name = ? LIMIT 1 " ); while (true) { $nameCheck->execute([ $gameId, $copyName, ]); if (!$nameCheck->fetchColumn()) { break; } $copyName = $baseName . ' - COPY ' . $copyNumber; $copyNumber++; if ($copyNumber > 9999) { throw new RuntimeException( 'Could not generate a unique Storylet copy name.' ); } } $sourceKey = trim( (string)( $source['storylet_key'] ?? '' ) ); if ($sourceKey === '') { $sourceKey = 'storylet-' . $storyletId; } $copyKey = substr( $sourceKey, 0, 80 ) . '-copy-' . bin2hex( random_bytes(6) ); $copyUuid = bin2hex( random_bytes(16) ); $parentId = (int)( $source['parent_storylet_id'] ?? 0 ); $branchSortOrder = (int)( $source['branch_sort_order'] ?? 0 ); $choiceSortOrder = (int)( $source['choice_sort_order'] ?? 0 ); $branchLabel = (string)( $source['branch_label'] ?? '' ); $choiceLabel = (string)( $source['choice_label'] ?? '' ); if ($parentId > 0) { $orderStmt = $pdo->prepare( " SELECT COALESCE(MAX(branch_sort_order), 0) FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? " ); $orderStmt->execute([ $gameId, $parentId, ]); $branchSortOrder = (int)$orderStmt->fetchColumn() + 10; $choiceSortOrder = $branchSortOrder; if (trim($branchLabel) !== '') { $branchLabel = rtrim($branchLabel) . ' - COPY'; } if (trim($choiceLabel) !== '') { $choiceLabel = rtrim($choiceLabel) . ' - COPY'; } } $insert = $pdo->prepare( " INSERT INTO wf_storylets ( uuid, game_id, name, storylet_key, description, conditions, effects, max_occurrences, trigger_type, status, config, parent_storylet_id, choice_label, choice_sort_order, branch_label, branch_sort_order ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?) " ); $insert->execute([ $copyUuid, $gameId, $copyName, $copyKey, $source['description'] ?? null, $source['conditions'] ?? null, $source['effects'] ?? null, $source['max_occurrences'] ?? null, $source['trigger_type'] ?? 'manual', $source['config'] ?? null, $parentId > 0 ? $parentId : null, $choiceLabel !== '' ? $choiceLabel : null, $choiceSortOrder, $branchLabel, $branchSortOrder, ]); $copyId = (int)$pdo->lastInsertId(); if ($copyId <= 0) { throw new RuntimeException( 'The Storylet copy could not be created.' ); } /* Reuse the same game-level media assets; do not duplicate files. */ $media = $pdo->prepare( " INSERT INTO wf_media_links ( media_id, object_type, object_id, slot ) SELECT media_id, object_type, ?, slot FROM wf_media_links WHERE object_type = 'wf_storylets' AND object_id = ? " ); $media->execute([ $copyId, $storyletId, ]); $pdo->commit(); return [ 'duplicated' => true, 'id' => $copyId, 'error' => '', ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } return [ 'duplicated' => false, 'id' => 0, 'error' => $exception->getMessage(), ]; } } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_LINEAR_TRAVEL_SAVE_V72 */ $regionTravel = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionTravelResult = buildRegionTravelFromPost( $record, isset($post['travel']) && is_array($post['travel']) ? $post['travel'] : [] ); $regionTravel = $regionTravelResult['travel'] ?? null; if ( ($regionTravelResult['error'] ?? '') !== '' ) { $error = (string)$regionTravelResult['error']; } } /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STATUS_LIFECYCLE_SAVE_V88 */ $statusLifecycle = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_statuses' ) { $statusLifecycleResult = gameplayBuildStatusLifecycleFromPost( $pdo, $gameId, $post['status_lifecycle'] ?? [] ); $statusLifecycle = $statusLifecycleResult['lifecycle'] ?? null; $record['_status_lifecycle_editor'] = is_array( $statusLifecycleResult['editor'] ?? null ) ? $statusLifecycleResult['editor'] : []; if ( ($statusLifecycleResult['error'] ?? '') !== '' ) { $error = (string)$statusLifecycleResult['error']; } } /* WAYFARER_STORYLET_PARSE_V56 */ /* WAYFARER_STORYLET_BINDINGS_V79 */ $storyletBindings = []; $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $bindingResult = gameplayBuildStoryletBindingsFromPost( isset($post['bindings']) && is_array($post['bindings']) ? array_values($post['bindings']) : [] ); $storyletBindings = $bindingResult['bindings']; if ($bindingResult['error'] !== '') { $error = $bindingResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [], $storyletBindings, $postedId ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_LINEAR_TRAVEL_SAVE_V72 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTravel !== null) { $configToSave['travel'] = $regionTravel; } else { unset( $configToSave['travel'] ); } } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STATUS_LIFECYCLE_CONFIG_V88 */ if ( ($definition['table'] ?? '') === 'wf_statuses' ) { if (is_array($statusLifecycle)) { $configToSave['lifecycle'] = $statusLifecycle; } else { unset($configToSave['lifecycle']); } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { /* WAYFARER_STORYLET_BINDINGS_V79 */ if ($storyletBindings) { $configToSave['bindings'] = array_values($storyletBindings); } else { unset($configToSave['bindings']); } if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; /* * WAYFARER_STORYLET_UUID_SAVE_V90 * * Storylet UUID is an internal persistent identifier, not an authored * form value. Older/new-record builder state can therefore carry an * empty string here. Never send that empty value to the UNIQUE * wf_storylets.uuid column; create the identifier at insert time. * * Existing Storylet edits keep their stored UUID unchanged because * this branch runs only in the new-record INSERT path. */ if ( ($definition['table'] ?? '') === 'wf_storylets' && $fieldName === 'uuid' && trim((string)$value) === '' ) { $value = bin2hex( random_bytes(16) ); $record[$fieldName] = $value; } if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: _wayfarer_backups/v92-20260930-134321/config/builder-types.php ================================================================================ [ 'label' => 'Regions', 'singular' => 'Region', 'table' => 'wf_regions', 'list_field' => 'name', 'traits' => true, /* * Permanent deletion is available from the edit screen. * A Region cannot be deleted while Locations still belong to it. */ 'delete' => [ 'enabled' => true, 'dependencies' => [ [ 'table' => 'wf_locations', 'foreign_key' => 'region_id', 'label' => 'Locations' ] ] ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'navigation_type' => [ 'label' => 'Navigation Type', 'type' => 'select', 'required' => true, 'options' => [ 'linear' => 'Linear', 'grid' => 'Grid', 'freeform' => 'Freeform' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], /* WAYFARER_LINEAR_DISTANCE_LABEL_V61 */ 'config_fields' => [ 'distance_label' => [ 'label' => 'Distance Label', 'type' => 'text', 'default' => 'units', 'help' => 'Player-facing unit used for Linear travel distance, such as miles, km, leagues, or units.' ] ] ], 'locations' => [ 'label' => 'Locations', 'singular' => 'Location', 'table' => 'wf_locations', 'list_field' => 'name', 'traits' => true, 'list_columns' => [ 'name' => [ 'label' => 'Name', 'source' => 'field', 'field' => 'name', 'sortable' => true ], 'region' => [ 'label' => 'Region', 'source' => 'relationship', 'relationship' => 'region_id', 'sortable' => true ], 'position' => [ 'label' => 'Position', 'source' => 'config', 'field' => 'position', 'type' => 'number', 'sortable' => true ] ], /* * Permanent deletion is available from the edit screen. */ 'delete' => [ 'enabled' => true ], 'scope' => [ 'type' => 'region', 'field' => 'region_id', 'scope_table' => 'wf_regions', 'scope_key' => 'id', 'game_field' => 'game_id' ], 'relationships' => [ 'region_id' => [ 'label' => 'Region', 'type' => 'select_record', 'table' => 'wf_regions', 'value_field' => 'id', 'label_field' => 'name', 'game_field' => 'game_id', 'required' => true ] ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], 'config_fields' => [ 'position' => [ 'label' => 'Position', 'type' => 'number', 'min' => 0, 'required' => true, 'condition' => [ 'relationship' => 'region_id', 'source_table' => 'wf_regions', 'source_key' => 'id', 'source_field' => 'navigation_type', 'equals' => 'linear' ] ] ] ], 'entities' => [ 'label' => 'Entities', 'singular' => 'Entity', 'table' => 'wf_entities', 'list_field' => 'name', 'traits' => true, /* * Permanent deletion is available from the edit screen. * Additional dependency rules can be added here later as * Storylets and other authored objects begin referencing Entities. */ 'delete' => [ 'enabled' => true ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'entity_type' => [ 'label' => 'Entity Type', 'type' => 'select', 'required' => true, 'default' => 'item', 'options' => [ 'item' => 'Item', 'character' => 'Character' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], /* * System traits use the same stored config.traits collection as * author-created traits. They are locked in the authoring UI. */ 'system_traits' => [ 'selector_field' => 'entity_type', 'after_field' => 'entity_type', 'groups' => [ 'item' => [ 'label' => 'Item Settings', 'help' => 'These are required Item properties used by Wayfarer. ' . 'They cannot be removed or renamed.', 'traits' => [ 'quantity' => [ /* * WAYFARER_ITEM_QUANTITY_CONTEXT_V44 */ 'label' => 'Starting Quantity', 'type' => 'number', 'default' => 0, 'min' => 0, 'required' => true, /* * The minimum is a Wayfarer system rule, not an * author-editable gameplay setting. */ 'show_system_min' => true, 'min_label' => 'Minimum Quantity', 'min_help' => 'Wayfarer Item quantities cannot fall below 0.', 'allow_author_max' => true, 'max_label' => 'Maximum Quantity', 'max_help' => 'The maximum quantity the player can hold during gameplay. ' . 'Leave blank for no maximum.', 'help' => 'Sets the quantity of this Item when gameplay is initialized. ' . 'Effects may increase or decrease the quantity during play. ' . 'A Starting Quantity of 0 means the player begins without this Item.' ], 'inventory_visibility' => [ 'label' => 'Inventory Visibility', 'type' => 'select', 'default' => 'while_possessed', 'required' => true, 'options' => [ 'while_possessed' => 'Only while possessed', 'once_discovered' => 'Once discovered', 'always' => 'Always', 'never' => 'Never' ], 'help' => 'Controls when this item appears in the player inventory. ' . '"Only while possessed" hides the item whenever its quantity ' . 'is 0. "Once discovered" hides it until the player first ' . 'obtains or discovers it, then keeps it visible even if its ' . 'quantity later returns to 0. "Always" shows the item from ' . 'the beginning of the game. "Never" keeps the item hidden ' . 'from the player inventory while still allowing gameplay ' . 'conditions and effects to use it.' ] ] ], 'character' => [ 'label' => 'Character Settings', 'help' => 'These are required Character properties used by Wayfarer. ' . 'They cannot be removed or renamed.', 'traits' => [ 'party_member' => [ 'label' => 'Starts in Party', 'type' => 'binary', 'default' => 0, 'required' => true, 'help' => 'Determines whether this character begins the game ' . 'as a member of the player party. No stores 0; ' . 'Yes stores 1. Storylets can later change this ' . 'state to add or remove the character from the party.' ] ] ] ] ] ], /* WAYFARER_STATUSES_V87 */ 'statuses' => [ 'label' => 'Statuses', 'singular' => 'Status', 'table' => 'wf_statuses', 'list_field' => 'name', 'list_columns' => [ 'name' => [ 'label' => 'Name', 'source' => 'field', 'field' => 'name', 'sortable' => true ], 'status' => [ 'label' => 'Status', 'source' => 'field', 'field' => 'status', 'sortable' => true ] ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true, 'help' => 'Author-facing and player-facing name of this persistent Status.' ], 'description' => [ 'label' => 'Description', 'type' => 'textarea', 'help' => 'Optional player-facing description shown while this Status is active.' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ], 'help' => 'Only Active Status definitions are available to gameplay Conditions and Effects.' ] ], 'config_fields' => [ 'show_to_player' => [ 'label' => 'Show to Player', 'type' => 'select', 'default' => '1', 'required' => true, 'options' => [ '1' => 'Yes', '0' => 'No' ], 'help' => 'If Yes, the Status appears beneath the affected party member on the player Status screen.' ], /* WAYFARER_STATUS_LIFECYCLE_DISPLAY_V88 */ 'show_stage_to_player' => [ 'label' => 'Show Current Stage to Player', 'type' => 'select', 'default' => '1', 'required' => true, 'options' => [ '1' => 'Yes', '0' => 'No' ], 'help' => 'If Yes, a timed Status may show its current Stage beneath the Status name.' ], 'show_time_remaining' => [ 'label' => 'Show Time Remaining to Player', 'type' => 'select', 'default' => '0', 'required' => true, 'options' => [ '1' => 'Yes', '0' => 'No' ], 'help' => 'If Yes, a timed Status may show how many ticks remain before its next Outcome check.' ] ] ], /* WAYFARER_STORYLET_BUILDER_V56 */ 'storylets' => [ 'label' => 'Storylets', 'singular' => 'Storylet', 'table' => 'wf_storylets', 'list_field' => 'name', 'list_columns' => [ 'name' => [ 'label' => 'Name', 'source' => 'field', 'field' => 'name', 'sortable' => true ], 'parent' => [ 'label' => 'Parent', 'source' => 'relationship', 'relationship' => 'parent_storylet_id', 'sortable' => true ], 'trigger' => [ 'label' => 'Trigger', 'source' => 'field', 'field' => 'trigger_type', 'sortable' => true ], 'status' => [ 'label' => 'Status', 'source' => 'field', 'field' => 'status', 'sortable' => true ] ], /* * A Storylet with children cannot be permanently removed until its * Branch Storylets have been detached or deleted. */ 'delete' => [ 'enabled' => true, 'dependencies' => [ [ 'table' => 'wf_storylets', 'foreign_key' => 'parent_storylet_id', 'label' => 'Branch Storylets' ] ] ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'relationships' => [ 'parent_storylet_id' => [ 'label' => 'Parent Storylet', 'type' => 'select_record', 'table' => 'wf_storylets', 'value_field' => 'id', 'label_field' => 'name', 'game_field' => 'game_id', 'required' => false, 'exclude_current' => true, 'help' => 'Leave blank for a root Storylet. Select a parent to make ' . 'this Storylet one of that Storylet\'s player Branches.' ] ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true, 'help' => 'Author-facing Storylet name and default player-facing title.' ], 'description' => [ 'label' => 'Narrative / Description', 'type' => 'textarea', 'help' => 'Narrative shown when this Storylet opens. It may also be ' . 'used as preview text when the Storylet is offered.' ], 'branch_label' => [ 'label' => 'Branch Label', 'type' => 'text', 'help' => 'Used when this Storylet appears as a child Branch. Leave ' . 'blank to use the Storylet Name.' ], 'branch_sort_order' => [ 'label' => 'Branch Order', 'type' => 'number', 'min' => 0, 'default' => 0, 'help' => 'Lower numbers appear first among sibling Branches. Ignored ' . 'for root Storylets.' ], 'trigger_type' => [ 'label' => 'Trigger Type', 'type' => 'select', 'required' => true, 'default' => 'manual', 'options' => [ 'manual' => 'Manual', 'automatic' => 'Automatic', 'chance' => 'Chance', /* WAYFARER_EVENT_STORYLETS_V88 */ 'event' => 'Event / Linked Only' ], 'help' => 'Manual: offer while eligible. Automatic: fire when it becomes ' . 'eligible. Chance: while eligible, roll the base chance each tick. ' . 'Event / Linked Only: never offer as a normal Action; open it from ' . 'a Status Outcome or another linked gameplay event.' ], 'max_occurrences' => [ 'label' => 'Maximum Occurrences', 'type' => 'number', 'min' => 1, 'help' => 'Leave blank for unlimited occurrences.' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], 'config_fields' => [ 'priority' => [ 'label' => 'Priority', 'type' => 'number', 'default' => 100, 'help' => 'Author-controlled gameplay order for competing Storylets. ' . 'Lower numbers are evaluated first.' ], 'tick_cost' => [ 'label' => 'Tick Cost', 'type' => 'number', 'min' => 0, 'default' => 0, 'help' => 'How many normal gameplay ticks resolving this Storylet consumes. ' . '0 consumes no time; 1 or more runs the normal tick pipeline.' ], 'chance_percent' => [ 'label' => 'Base Chance %', 'type' => 'number', 'min' => 0, 'max' => 100, 'step' => 0.01, 'help' => 'Used only by Chance Storylets. Eligibility is checked first; ' . 'then this percentage is rolled on each gameplay tick.' ], 'interrupts_travel' => [ 'label' => 'Interrupts Travel', 'type' => 'select', 'default' => '0', 'options' => [ '0' => 'No', '1' => 'Yes' ], 'help' => 'Used by Automatic and Chance Storylets. If Yes, firing the ' . 'Storylet stops the current travel activity. Manual Storylets ' . 'are selected by the player and do not use this setting.' ] ] ] ]; ================================================================================ FILE: _wayfarer_backups/v92-20260930-134321/includes/builder/editor.php ================================================================================ $groupValues) { if (!is_array($groupValues)) { continue; } foreach ($groupValues as $traitName => $traitValue) { if (!isset($storedSystemTraitValues[$groupName][$traitName])) { $storedSystemTraitValues[$groupName][$traitName] = $traitValue; } } } } } $renderSystemTraits = function () use ( $record, $fields, $systemTraitsSelector, $systemTraitGroups, $storedSystemTraitValues, $storedSystemTraitMaxValues ) { if (!$systemTraitsSelector || empty($systemTraitGroups)) { return; } $selectedGroup = $record[$systemTraitsSelector] ?? ($fields[$systemTraitsSelector]['default'] ?? ''); ?>
$groupDefinition): ?>
>

$traitDefinition): ?>

>

Storylet duplicated. This copy is saved as Draft.

Copies the saved Storylet into a new Draft. If this is a Branch, the copy becomes a sibling Branch. Child Branches are not copied.
$relationship): ?> $field): ?>

Media

Optional. Reuse an existing asset from this game's Media Library, upload a new asset, or leave the slot empty.

$mediaDefinition): ?>

Maximum upload: .

Current: ()



No existing assets have been uploaded to this game yet.

$field): ?>






Traits

Traits are additional values associated with this that can change during play.


Danger Zone

Permanently delete this . This cannot be undone.

================================================================================ FILE: _wayfarer_backups/v92-20260930-134321/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects, array $storyletBindings = [], int $currentStoryletId = 0 ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $allowedTargets = array_replace( gameplayRuntimeStateTargets( $pdo, $gameId ), gameplayStoryletBindingEffectTargets( $pdo, $gameId, $storyletBindings ), gameplayStoryletNavigationEffectTargets( $pdo, $gameId, $currentStoryletId ) ); $result = gameplayBuildEffectsFromPost( $allowedTargets, $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); if (($result['error'] ?? '') !== '') { return $result; } $transitionCount = 0; foreach ( $result['effects'] ?? [] as $effect ) { if ( is_array($effect) && (string)( $effect['target_scope'] ?? '' ) === 'storylet' && (string)( $effect['target_type'] ?? '' ) === 'transition' ) { $transitionCount++; } } if ($transitionCount > 1) { return [ 'effects' => [], 'error' => 'A Storylet may have only one Storylet → Continue To Effect.', ]; } return $result; } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /* * WAYFARER_LINEAR_TRAVEL_SAVE_V72 * * Dedicated Linear Travel is deliberately opt-in. A null travel result means * the Region should continue using the legacy Position Tick Effect path. */ function buildRegionTravelFromPost( array $record, array $postedTravel ): array { $navigationType = strtolower( trim( (string)( $record['navigation_type'] ?? '' ) ) ); if ($navigationType !== 'linear') { return [ 'travel' => null, 'error' => '', ]; } if (empty($postedTravel['enabled'])) { return [ 'travel' => null, 'error' => '', ]; } $baseDistance = $postedTravel['base_distance'] ?? ''; if ( !is_numeric($baseDistance) || (float)$baseDistance <= 0 ) { return [ 'travel' => null, 'error' => 'Base Distance Per Tick must be greater than zero.', ]; } $baseDistance = (float)$baseDistance; if (floor($baseDistance) === $baseDistance) { $baseDistance = (int)$baseDistance; } return [ 'travel' => [ 'enabled' => true, 'base_distance' => $baseDistance, 'allow_turnaround' => !empty( $postedTravel[ 'allow_turnaround' ] ), ], 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Storylet duplication | WAYFARER_STORYLET_DUPLICATE_V83 |-------------------------------------------------------------------------- | | Duplicate the currently saved Storylet as a Draft. The duplicate copies | the selected Storylet record and its media links, but intentionally does | not recursively copy child Branch Storylets. | */ function duplicateStoryletRecord( PDO $pdo, int $gameId, int $storyletId ): array { if ($gameId <= 0 || $storyletId <= 0) { return [ 'duplicated' => false, 'id' => 0, 'error' => 'A valid Storylet is required to duplicate.', ]; } try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, uuid, game_id, name, storylet_key, description, conditions, effects, max_occurrences, trigger_type, status, config, parent_storylet_id, choice_label, choice_sort_order, branch_label, branch_sort_order FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $storyletId, $gameId, ]); $source = $stmt->fetch(PDO::FETCH_ASSOC); if (!$source) { throw new RuntimeException( 'The Storylet to duplicate could not be found.' ); } $sourceName = trim( (string)( $source['name'] ?? 'Storylet' ) ); if ($sourceName === '') { $sourceName = 'Storylet'; } $baseName = preg_replace( '/\s+-\s+COPY(?:\s+\d+)?$/i', '', $sourceName ); if (!is_string($baseName) || trim($baseName) === '') { $baseName = $sourceName; } $baseName = trim($baseName); /* Leave room for " - COPY 9999" within the 200-character Name. */ $baseName = function_exists('mb_substr') ? mb_substr($baseName, 0, 185, 'UTF-8') : substr($baseName, 0, 185); $copyName = $baseName . ' - COPY'; $copyNumber = 2; $nameCheck = $pdo->prepare( " SELECT 1 FROM wf_storylets WHERE game_id = ? AND name = ? LIMIT 1 " ); while (true) { $nameCheck->execute([ $gameId, $copyName, ]); if (!$nameCheck->fetchColumn()) { break; } $copyName = $baseName . ' - COPY ' . $copyNumber; $copyNumber++; if ($copyNumber > 9999) { throw new RuntimeException( 'Could not generate a unique Storylet copy name.' ); } } $sourceKey = trim( (string)( $source['storylet_key'] ?? '' ) ); if ($sourceKey === '') { $sourceKey = 'storylet-' . $storyletId; } $copyKey = substr( $sourceKey, 0, 80 ) . '-copy-' . bin2hex( random_bytes(6) ); $copyUuid = bin2hex( random_bytes(16) ); $parentId = (int)( $source['parent_storylet_id'] ?? 0 ); $branchSortOrder = (int)( $source['branch_sort_order'] ?? 0 ); $choiceSortOrder = (int)( $source['choice_sort_order'] ?? 0 ); $branchLabel = (string)( $source['branch_label'] ?? '' ); $choiceLabel = (string)( $source['choice_label'] ?? '' ); if ($parentId > 0) { $orderStmt = $pdo->prepare( " SELECT COALESCE(MAX(branch_sort_order), 0) FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? " ); $orderStmt->execute([ $gameId, $parentId, ]); $branchSortOrder = (int)$orderStmt->fetchColumn() + 10; $choiceSortOrder = $branchSortOrder; if (trim($branchLabel) !== '') { $branchLabel = rtrim($branchLabel) . ' - COPY'; } if (trim($choiceLabel) !== '') { $choiceLabel = rtrim($choiceLabel) . ' - COPY'; } } $insert = $pdo->prepare( " INSERT INTO wf_storylets ( uuid, game_id, name, storylet_key, description, conditions, effects, max_occurrences, trigger_type, status, config, parent_storylet_id, choice_label, choice_sort_order, branch_label, branch_sort_order ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?) " ); $insert->execute([ $copyUuid, $gameId, $copyName, $copyKey, $source['description'] ?? null, $source['conditions'] ?? null, $source['effects'] ?? null, $source['max_occurrences'] ?? null, $source['trigger_type'] ?? 'manual', $source['config'] ?? null, $parentId > 0 ? $parentId : null, $choiceLabel !== '' ? $choiceLabel : null, $choiceSortOrder, $branchLabel, $branchSortOrder, ]); $copyId = (int)$pdo->lastInsertId(); if ($copyId <= 0) { throw new RuntimeException( 'The Storylet copy could not be created.' ); } /* Reuse the same game-level media assets; do not duplicate files. */ $media = $pdo->prepare( " INSERT INTO wf_media_links ( media_id, object_type, object_id, slot ) SELECT media_id, object_type, ?, slot FROM wf_media_links WHERE object_type = 'wf_storylets' AND object_id = ? " ); $media->execute([ $copyId, $storyletId, ]); $pdo->commit(); return [ 'duplicated' => true, 'id' => $copyId, 'error' => '', ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } return [ 'duplicated' => false, 'id' => 0, 'error' => $exception->getMessage(), ]; } } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_LINEAR_TRAVEL_SAVE_V72 */ $regionTravel = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionTravelResult = buildRegionTravelFromPost( $record, isset($post['travel']) && is_array($post['travel']) ? $post['travel'] : [] ); $regionTravel = $regionTravelResult['travel'] ?? null; if ( ($regionTravelResult['error'] ?? '') !== '' ) { $error = (string)$regionTravelResult['error']; } } /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STATUS_LIFECYCLE_SAVE_V88 */ $statusLifecycle = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_statuses' ) { $statusLifecycleResult = gameplayBuildStatusLifecycleFromPost( $pdo, $gameId, $post['status_lifecycle'] ?? [] ); $statusLifecycle = $statusLifecycleResult['lifecycle'] ?? null; $record['_status_lifecycle_editor'] = is_array( $statusLifecycleResult['editor'] ?? null ) ? $statusLifecycleResult['editor'] : []; if ( ($statusLifecycleResult['error'] ?? '') !== '' ) { $error = (string)$statusLifecycleResult['error']; } } /* WAYFARER_STORYLET_PARSE_V56 */ /* WAYFARER_STORYLET_BINDINGS_V79 */ $storyletBindings = []; $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $bindingResult = gameplayBuildStoryletBindingsFromPost( isset($post['bindings']) && is_array($post['bindings']) ? array_values($post['bindings']) : [] ); $storyletBindings = $bindingResult['bindings']; if ($bindingResult['error'] !== '') { $error = $bindingResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [], $storyletBindings, $postedId ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_LINEAR_TRAVEL_SAVE_V72 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTravel !== null) { $configToSave['travel'] = $regionTravel; } else { unset( $configToSave['travel'] ); } } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STATUS_LIFECYCLE_CONFIG_V88 */ if ( ($definition['table'] ?? '') === 'wf_statuses' ) { if (is_array($statusLifecycle)) { $configToSave['lifecycle'] = $statusLifecycle; } else { unset($configToSave['lifecycle']); } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { /* WAYFARER_STORYLET_BINDINGS_V79 */ if ($storyletBindings) { $configToSave['bindings'] = array_values($storyletBindings); } else { unset($configToSave['bindings']); } if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } /* * WAYFARER_STORYLET_INTERNAL_IDS_V91 * * uuid and storylet_key are structural Storylet identifiers, not * author-facing Builder fields. The generic INSERT therefore has to add * them explicitly for a new wf_storylets row. */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { $storyletUuid = bin2hex( random_bytes(16) ); $storyletKey = 'storylet-' . substr( $storyletUuid, 0, 24 ); $columns[] = '`uuid`'; $placeholders[] = '?'; $values[] = $storyletUuid; $columns[] = '`storylet_key`'; $placeholders[] = '?'; $values[] = $storyletKey; /* * Keep returned editor state coherent even though these values are * intentionally not displayed as authored fields. */ $record['uuid'] = $storyletUuid; $record['storylet_key'] = $storyletKey; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; /* * WAYFARER_STORYLET_UUID_SAVE_V90 * * Storylet UUID is an internal persistent identifier, not an authored * form value. Older/new-record builder state can therefore carry an * empty string here. Never send that empty value to the UNIQUE * wf_storylets.uuid column; create the identifier at insert time. * * Existing Storylet edits keep their stored UUID unchanged because * this branch runs only in the new-record INSERT path. */ if ( ($definition['table'] ?? '') === 'wf_storylets' && $fieldName === 'uuid' && trim((string)$value) === '' ) { $value = bin2hex( random_bytes(16) ); $record[$fieldName] = $value; } if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: _wayfarer_backups/v92-20260930-134321/includes/builder/status-lifecycle.php ================================================================================ Select root Storylet...'; foreach ($storylets as $storylet) { $id = (int)($storylet['id'] ?? 0); if ($id <= 0) { continue; } $name = trim((string)($storylet['name'] ?? ('Storylet ' . $id))); $status = strtolower(trim((string)($storylet['status'] ?? 'draft'))); $trigger = strtolower(trim((string)($storylet['trigger_type'] ?? 'manual'))); $suffix = []; if ($status !== 'active') { $suffix[] = ucfirst($status); } if ($trigger === 'event') { $suffix[] = 'Event'; } $label = $name; if ($suffix) { $label .= ' [' . implode(', ', $suffix) . ']'; } echo ''; } }; $statusLifecycleRenderResultOptions = static function ( array $stages, string $selected ): void { echo ''; echo ''; foreach ($stages as $stage) { if (!is_array($stage)) { continue; } $key = trim((string)($stage['key'] ?? '')); $name = trim((string)($stage['name'] ?? $key)); if ($key === '') { continue; } $value = 'stage:' . $key; echo ''; } }; /* WAYFARER_STATUS_SUBJECT_EFFECTS_EDITOR_V89 */ $statusLifecycleTraitCatalog = gameplayStatusLifecycleSubjectTraitCatalog( $pdo, (int)$gameId ); $statusLifecycleRenderTraitOptions = static function ( array $catalog, string $selected = '' ): void { echo ''; foreach ($catalog as $targetId => $definition) { if (!is_array($definition)) { continue; } $name = trim((string)($definition['name'] ?? '')); $dataType = strtolower(trim((string)($definition['data_type'] ?? 'number'))); if ($name === '') { continue; } echo ''; } }; $statusLifecycleRenderEffectRow = static function ( array $effect, string $namePrefix, bool $recurring, array $traitCatalog, callable $renderTraitOptions ): void { $effectKey = trim((string)($effect['key'] ?? '')); $targetId = trim((string)($effect['target_id'] ?? '')); $operation = strtolower(trim((string)($effect['operation'] ?? 'set'))); $valueMode = strtolower(trim((string)($effect['value_mode'] ?? 'fixed'))); $value = $effect['value'] ?? 0; $valueMin = $effect['value_min'] ?? 0; $valueMax = $effect['value_max'] ?? 0; $interval = max(1, (int)($effect['interval_ticks'] ?? 1)); $dataType = isset($traitCatalog[$targetId]) ? (string)($traitCatalog[$targetId]['data_type'] ?? 'number') : strtolower(trim((string)($effect['data_type'] ?? 'number'))); ?>
>

Status Progression

Manual Statuses stay active until an Effect changes them. Timed Stages advance independently for every affected character. The first Stage is entered when the Status becomes active.

>

Stages

Each Stage rolls its duration once on entry. When its timer expires, exactly one Outcome is chosen from the authored percentages.
$stage): ?>
Stage
On Enter Effects Applied once when this character enters the Stage.
$effect): ?>
While in Stage Effects Applied to the Status Subject every authored number of gameplay ticks. The first interval starts when the Stage starts.
$effect): ?>
Outcomes Chances for this Stage must total exactly 100%.
$outcome): ?>
Outcome Effects Applied to the Status Subject before the Result transition and Storylet presentation.
$effect): ?>

When an Outcome opens a Storylet, Wayfarer supplies the affected character as the reserved subject binding. Use {{subject}} or {{subject.name}} in Storylet text. If the Storylet defines a normal Binding with Key subject, Status-triggered entry reuses the affected character instead of rolling a new one.

================================================================================ FILE: _wayfarer_backups/v92-20260930-134321/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* |-------------------------------------------------------------------------- | Party-member Trait catalog | WAYFARER_PARTY_TARGETS_V85 |-------------------------------------------------------------------------- | | Player Traits and authored Character Entity Traits may represent the same | logical per-person property (for example, Health). Party-level authoring | addresses that property by normalized name so one Effect can operate across | the current party without exposing runtime player-slot numbers. | */ function gameplayPartyTraitNormalizeName( string $value ): string { $value = trim( preg_replace( '/\\s+/u', ' ', $value ) ?? $value ); if (function_exists('mb_strtolower')) { return mb_strtolower( $value, 'UTF-8' ); } return strtolower($value); } function gameplayPartyTraitTargetId( string $traitName ): string { return 'party_name_' . bin2hex( gameplayPartyTraitNormalizeName( $traitName ) ); } function gameplayPartyTraitCatalog( PDO $pdo, int $gameId, ?array $playerTraitsOverride = null ): array { if ($gameId <= 0) { return []; } $catalog = []; $append = static function ( array &$catalog, array $trait ): void { if ( !is_array($trait) || !empty($trait['system']) || ( array_key_exists('active', $trait) && !$trait['active'] ) ) { return; } $name = trim( (string)( $trait['name'] ?? '' ) ); if ($name === '') { return; } $type = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); if ($type === 'binary') { $type = 'boolean'; } if ( !in_array( $type, ['number', 'boolean'], true ) ) { return; } $normalized = gameplayPartyTraitNormalizeName( $name ); if ($normalized === '') { return; } if (!isset($catalog[$normalized])) { $catalog[$normalized] = [ 'name' => $name, 'data_type' => $type, 'conflict' => false, ]; return; } if ( (string)$catalog[$normalized]['data_type'] !== $type ) { $catalog[$normalized]['conflict'] = true; } }; if ($playerTraitsOverride === null) { $stmt = $pdo->prepare( 'SELECT config FROM wf_games WHERE id = ? LIMIT 1' ); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); $config = $raw === false ? [] : gameplayRuntimeStateDecodeConfig( $raw ); $playerTraits = isset($config['player_traits']) && is_array($config['player_traits']) ? $config['player_traits'] : []; } else { $playerTraits = $playerTraitsOverride; } foreach ($playerTraits as $trait) { if (is_array($trait)) { $append( $catalog, $trait ); } } $stmt = $pdo->prepare( " SELECT config FROM wf_entities WHERE game_id = ? AND status = 'active' AND LOWER(entity_type) = 'character' ORDER BY id " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (is_array($trait)) { $append( $catalog, $trait ); } } } foreach ($catalog as $normalized => $definition) { if (!empty($definition['conflict'])) { unset($catalog[$normalized]); } } uasort( $catalog, static function ( array $a, array $b ): int { return strcasecmp( (string)$a['name'], (string)$b['name'] ); } ); return $catalog; } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } /* |-------------------------------------------------------------------------- | Status definitions / target metadata | WAYFARER_STATUSES_AUTHORING_V87 |-------------------------------------------------------------------------- */ function gameplayStatusDefinitions( PDO $pdo, int $gameId, bool $activeOnly = true ): array { if ($gameId <= 0) { return []; } $sql = " SELECT id, name, description, config, status FROM wf_statuses WHERE game_id = ? "; if ($activeOnly) { $sql .= " AND status = 'active'"; } $sql .= " ORDER BY name, id"; $stmt = $pdo->prepare($sql); $stmt->execute([$gameId]); $definitions = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $normalizeBoolean = static function ($value, bool $default): bool { if ($value === null || $value === '') { return $default; } if (is_bool($value)) { return $value; } return in_array( strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true ); }; $definitions[(string)$id] = [ 'id' => $id, 'name' => trim((string)($row['name'] ?? ('Status ' . $id))), 'description' => trim((string)($row['description'] ?? '')), 'status' => strtolower(trim((string)($row['status'] ?? 'draft'))), 'show_to_player' => $normalizeBoolean( $config['show_to_player'] ?? null, true ), /* WAYFARER_STATUS_LIFECYCLE_AUTHORING_V88 */ 'show_stage_to_player' => $normalizeBoolean( $config['show_stage_to_player'] ?? null, true ), 'show_time_remaining' => $normalizeBoolean( $config['show_time_remaining'] ?? null, false ), 'lifecycle' => isset($config['lifecycle']) && is_array($config['lifecycle']) ? $config['lifecycle'] : null, ]; } return $definitions; } function gameplayStatusTargetDefinition( string $scope, int $statusId, string $statusName, array $hierarchy, array $extra = [] ): array { return array_merge( [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'status', 'target_id' => (string)$statusId, 'trait_type' => 'boolean', 'data_type' => 'boolean', 'semantic_type' => 'status', 'helper' => 'Persistent Status: Active / Inactive', 'condition_operators' => [ 'equals', 'not_equals', ], 'effect_operations' => [ 'set', ], 'supports_random' => false, 'value_options' => [ '0' => 'Inactive', '1' => 'Active', ], 'group' => 'Statuses', 'label' => $statusName, 'hierarchy' => $hierarchy, ], $extra ); } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } /* WAYFARER_PARTY_TARGETS_V85 */ foreach ( gameplayPartyTraitCatalog( $pdo, $gameId, $playerTraits ) as $partyTrait ) { $partyName = (string)( $partyTrait['name'] ?? '' ); $partyDataType = (string)( $partyTrait['data_type'] ?? 'text' ); if ( $partyName === '' || !in_array( $partyDataType, ['number', 'boolean'], true ) ) { continue; } $partyTargetId = gameplayPartyTraitTargetId( $partyName ); $partyKey = 'party:trait:' . $partyTargetId; $targets[$partyKey] = [ 'scope' => 'party', 'target_scope' => 'party', 'target_type' => 'trait', 'target_id' => $partyTargetId, 'trait_type' => $partyDataType, 'data_type' => $partyDataType, 'semantic_type' => 'party_all_trait', 'helper' => 'Every current party member that exposes this Trait', 'condition_operators' => $partyDataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ], 'effect_operations' => $partyDataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ], 'supports_random' => $partyDataType === 'number', /* * A Party Condition needs an explicit Any/All quantifier model. * Until that exists, this aggregate target is Effect-only. */ 'condition_hidden' => true, 'group' => 'Party', 'label' => 'All Party Members → ' . $partyName, 'hierarchy' => [ [ 'key' => 'party', 'label' => 'Party', ], [ 'key' => 'all_party_members', 'label' => 'All Party Members', ], [ 'key' => $partyTargetId, 'label' => $partyName, ], ], ]; if ($partyDataType === 'boolean') { $targets[$partyKey]['value_options'] = [ '0' => 'False', '1' => 'True', ]; } } /* WAYFARER_STATUS_TARGETS_V87 */ $statusDefinitions = gameplayStatusDefinitions( $pdo, $gameId, true ); foreach ($statusDefinitions as $statusDefinition) { $statusId = (int)($statusDefinition['id'] ?? 0); $statusName = trim((string)($statusDefinition['name'] ?? '')); if ($statusId <= 0 || $statusName === '') { continue; } $partyStatusKey = 'party:status:' . $statusId; $targets[$partyStatusKey] = gameplayStatusTargetDefinition( 'party', $statusId, $statusName, [ [ 'key' => 'party', 'label' => 'Party', ], [ 'key' => 'all_party_members', 'label' => 'All Party Members', ], [ 'key' => 'status:' . $statusId, 'label' => 'Status — ' . $statusName, ], ], [ /* Party Status Conditions need explicit Any/All semantics. */ 'condition_hidden' => true, 'group' => 'Party', 'label' => 'All Party Members → Status — ' . $statusName, ] ); for ($slot = 0; $slot < $playerSlots; $slot++) { $playerStatusKey = 'player:' . $slot . ':status:' . $statusId; $targets[$playerStatusKey] = gameplayStatusTargetDefinition( 'player:' . $slot, $statusId, $statusName, [ [ 'key' => 'player', 'label' => 'Player Character', ], [ 'key' => 'player:' . $slot, 'label' => 'Player ' . ($slot + 1), ], [ 'key' => 'status:' . $statusId, 'label' => 'Status — ' . $statusName, ], ], [ /* Player slots remain a runtime compatibility address. */ 'condition_hidden' => true, 'effect_hidden' => true, ] ); } } /* * Numbered Player slots remain runtime-compatible for existing authored * data, but they are implementation slots rather than useful new targets. * New authoring uses Storylet Binding -> Random Party Member or * Party -> All Party Members instead. */ for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); $playerKey = 'player:' . $slot . ':trait:' . $traitId; if ( $traitId !== '' && isset($targets[$playerKey]) ) { $targets[$playerKey]['condition_hidden'] = true; $targets[$playerKey]['effect_hidden'] = true; } } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } if ($entityType === 'character') { foreach ($statusDefinitions as $statusDefinition) { $statusId = (int)($statusDefinition['id'] ?? 0); $statusName = trim((string)($statusDefinition['name'] ?? '')); if ($statusId <= 0 || $statusName === '') { continue; } $statusKey = 'entity:' . $id . ':status:' . $statusId; $targets[$statusKey] = gameplayStatusTargetDefinition( 'entity:' . $id, $statusId, $statusName, [ [ 'key' => 'character', 'label' => 'Character', ], [ 'key' => 'entity:' . $id, 'label' => $name, ], [ 'key' => 'status:' . $statusId, 'label' => 'Status — ' . $statusName, ], ], [ 'group' => 'Characters', 'label' => $name . ' → Status — ' . $statusName, ] ); } } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ /* |-------------------------------------------------------------------------- | Navigation Condition Targets |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITION_TARGETS_V75 | WAYFARER_LIVE_LOCATION_ANCHORS_V76 | | Numeric Position Conditions are Region-qualified. | | New Location Conditions reference wf_locations.id as target_id. The | comparison itself is against zero because runtime resolves the target to: | | current position - current anchor position | | That makes a Location a live spatial anchor: | | = 0 => At | != 0 => Not at | < 0 => Before | <= 0 => At or before | > 0 => After | >= 0 => At or after | | The V75 snapshot target navigation:location:location remains hidden for | backward compatibility only. | */ function gameplayConditionNavigationPositionToken( $value ): string { if (!is_numeric($value)) { return ''; } $number = (float)$value; if (is_finite($number) && floor($number) == $number) { return (string)((int)$number); } $token = rtrim( rtrim( sprintf('%.12F', $number), '0' ), '.' ); return $token === '-0' ? '0' : $token; } function gameplayNavigationConditionTargets( PDO $pdo, int $gameId ): array { $targets = []; /* * Compatibility only. Existing saved unqualified Position Conditions * still render and save, but cannot be newly authored. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position_legacy', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'group' => 'Navigation', 'label' => 'Position (legacy unqualified)', 'condition_hidden' => true, ]; /* * Compatibility only. V75 stored Region + Position in the value itself. * New V76 Conditions never use this target. */ $targets['navigation:location:location'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => 'location', 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'navigation_location_v75_snapshot', 'helper' => 'V75 snapshot', 'condition_operators' => [ 'equals', 'not_equals', ], 'group' => 'Navigation', 'label' => 'Location (legacy V75 snapshot)', 'condition_hidden' => true, ]; if ($gameId <= 0) { return $targets; } /* * Fixed numeric Position, explicitly qualified to one linear Region. */ $regionStmt = $pdo->prepare( " SELECT id, name, navigation_type FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $regionStmt->execute([ $gameId ]); foreach ( $regionStmt->fetchAll(PDO::FETCH_ASSOC) as $region ) { $regionId = (int)($region['id'] ?? 0); if ($regionId <= 0) { continue; } if ( strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) !== 'linear' ) { continue; } $regionName = trim( (string)( $region['name'] ?? ('Region ' . $regionId) ) ); $scope = 'navigation:' . $regionId; $key = $scope . ':position:position'; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_region_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'group' => 'Navigation — Positions', 'label' => $regionName, ]; } /* * Named Locations are LIVE references. Each Location is its own target, * identified by wf_locations.id. Position is shown only as authoring help; * it is resolved again from current Location config at runtime. */ $locationStmt = $pdo->prepare( " SELECT l.id, l.name, l.config, l.region_id, r.name AS region_name, r.navigation_type FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $locationStmt->execute([ $gameId ]); foreach ( $locationStmt->fetchAll(PDO::FETCH_ASSOC) as $location ) { if ( strtolower( trim( (string)( $location['navigation_type'] ?? '' ) ) ) !== 'linear' ) { continue; } $locationId = (int)( $location['id'] ?? 0 ); if ($locationId <= 0) { continue; } $config = gameplayRuntimeStateDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $position = gameplayConditionNavigationPositionToken( $config['position'] ); if ($position === '') { continue; } $locationName = trim( (string)( $location['name'] ?? ('Location ' . $locationId) ) ); $regionName = trim( (string)( $location['region_name'] ?? 'Region' ) ); $key = 'navigation:location:' . $locationId; $targets[$key] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => (string)$locationId, 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_location_anchor', 'helper' => $regionName . ' · Position ' . $position, 'condition_operators' => [ 'equals', 'not_equals', 'less_than', 'less_than_or_equal', 'greater_than', 'greater_than_or_equal', ], 'condition_operator_labels' => [ 'equals' => 'At', 'not_equals' => 'Not at', 'less_than' => 'Before', 'less_than_or_equal' => 'At or before', 'greater_than' => 'After', 'greater_than_or_equal' => 'At or after', ], /* * The runtime value is a signed delta from this anchor, so every * comparison is against zero. Keep that implementation detail out * of the authoring UI while still posting a normal Condition value. */ 'value_options' => [ '0' => 'Anchor', ], 'condition_hide_value' => true, 'group' => 'Navigation — Locations', 'label' => $locationName, ]; } return $targets; } /* * Allows Condition POST validation to recognize V76 navigation-only target * shapes even when a caller supplied the older universal Effect catalog. */ /* |-------------------------------------------------------------------------- | Storylet Navigation Effect targets | WAYFARER_STORYLET_ROOT_CONNECTIONS_V82 |-------------------------------------------------------------------------- | | Explicit Storylet navigation is an Effect-only target. A Storylet may | continue directly to another root Storylet after its other Effects resolve. | Child Storylets remain structural Branches and are deliberately not exposed | as destinations here. */ function gameplayStoryletNavigationEffectTargets( PDO $pdo, int $gameId, int $currentStoryletId = 0 ): array { $targets = []; if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, status FROM wf_storylets WHERE game_id = ? AND ( parent_storylet_id IS NULL OR parent_storylet_id = 0 ) ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $storylet ) { $storyletId = (int)( $storylet['id'] ?? 0 ); if ( $storyletId <= 0 || ( $currentStoryletId > 0 && $storyletId === $currentStoryletId ) ) { continue; } $name = trim( (string)( $storylet['name'] ?? '' ) ); if ($name === '') { $name = 'Storylet ' . $storyletId; } $status = strtolower( trim( (string)( $storylet['status'] ?? '' ) ) ); $helper = $status !== '' ? ucfirst($status) . ' root Storylet' : 'Root Storylet'; $key = 'storylet:transition:' . $storyletId; $targets[$key] = [ 'scope' => 'storylet', 'target_scope' => 'storylet', 'target_type' => 'transition', 'target_id' => (string)$storyletId, 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'storylet_transition', 'helper' => $helper, 'effect_operations' => [ 'continue', ], 'supports_random' => false, 'group' => 'Storylet Navigation', 'label' => $name . ' → Continue To', 'hierarchy' => [ [ 'key' => 'storylet', 'label' => 'Storylet', ], [ 'key' => 'storylet:' . $storyletId, 'label' => $name, ], [ 'key' => 'continue_to', 'label' => 'Continue To', ], ], ]; } return $targets; } /* |-------------------------------------------------------------------------- | Hierarchical target-ready Time Conditions | WAYFARER_HIERARCHICAL_TARGETS_V80 |-------------------------------------------------------------------------- | | The hierarchy UI consumes a generic target catalog. New systems can opt in | simply by supplying a three-level hierarchy array: | | Type -> Specific Target -> Property | | Current Game Time is Condition-only. Effects do not modify the clock here. | Calendar / Seasons can later register additional Time properties without | changing the picker. */ function gameplayTimeConditionTargets( PDO $pdo, int $gameId ): array { $targets = []; if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ($raw === false) { return $targets; } $config = gameplayRuntimeStateDecodeConfig( $raw ); $time = isset($config['time']) && is_array($config['time']) ? $config['time'] : []; $mode = strtolower( trim( (string)( $time['mode'] ?? 'none' ) ) ); if ( $mode !== 'days' && $mode !== 'hours' ) { return $targets; } $base = [ 'scope' => 'time', 'target_scope' => 'time', 'target_type' => 'clock', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'game_time', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'group' => 'Time', ]; $targets['time:clock:day'] = array_merge( $base, [ 'target_id' => 'day', 'label' => 'Game Clock → Day', 'hierarchy' => [ [ 'key' => 'time', 'label' => 'Time', ], [ 'key' => 'game_clock', 'label' => 'Game Clock', ], [ 'key' => 'day', 'label' => 'Day', ], ], ] ); if ($mode === 'hours') { $targets['time:clock:hour'] = array_merge( $base, [ 'target_id' => 'hour', 'label' => 'Game Clock → Hour', 'hierarchy' => [ [ 'key' => 'time', 'label' => 'Time', ], [ 'key' => 'game_clock', 'label' => 'Game Clock', ], [ 'key' => 'hour', 'label' => 'Hour', ], ], ] ); } return $targets; } function gameplaySyntheticTimeConditionTarget( string $target ): ?array { if ($target === 'time:clock:day') { return [ 'scope' => 'time', 'target_scope' => 'time', 'target_type' => 'clock', 'target_id' => 'day', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'game_time', 'condition_operators' => wayfarerConditionOperators(), ]; } if ($target === 'time:clock:hour') { return [ 'scope' => 'time', 'target_scope' => 'time', 'target_type' => 'clock', 'target_id' => 'hour', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'game_time', 'condition_operators' => wayfarerConditionOperators(), ]; } return null; } function gameplaySyntheticNavigationConditionTarget( string $target ): ?array { if ( preg_match( '/^navigation:([1-9][0-9]*):position:position$/', $target, $match ) === 1 ) { $scope = 'navigation:' . (int)$match[1]; return [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_region_position', 'condition_operators' => wayfarerConditionOperators(), ]; } if ( preg_match( '/^navigation:location:([1-9][0-9]*)$/', $target, $match ) === 1 ) { return [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => (string)((int)$match[1]), 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_location_anchor', 'condition_operators' => [ 'equals', 'not_equals', 'less_than', 'less_than_or_equal', 'greater_than', 'greater_than_or_equal', ], ]; } /* V75 snapshot compatibility. */ if ($target === 'navigation:location:location') { return [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => 'location', 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'navigation_location_v75_snapshot', 'condition_operators' => [ 'equals', 'not_equals', ], ]; } return null; } function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return array_replace( gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ), gameplayNavigationConditionTargets( $pdo, $gameId ), gameplayTimeConditionTargets( $pdo, $gameId ) ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; /* * WAYFARER_LOCATION_CONDITION_TARGETS_V75 * * Older callers may still pass the universal Effect target catalog. * Region-qualified Position and Location targets are Condition-only, * so synthesize their definitions here when needed. */ if ( $target !== '' && !isset($allowedTargets[$target]) ) { $syntheticTarget = gameplaySyntheticNavigationConditionTarget( $target ); if ( $syntheticTarget === null && function_exists( 'gameplaySyntheticTimeConditionTarget' ) ) { $syntheticTarget = gameplaySyntheticTimeConditionTarget( $target ); } if ($syntheticTarget !== null) { $allowedTargets[$target] = $syntheticTarget; } } if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: _wayfarer_backups/v92-20260930-134321/includes/gameplay/status-lifecycle.php ================================================================================ prepare( " SELECT id, name, trigger_type, status FROM wf_storylets WHERE game_id = ? AND parent_storylet_id IS NULL ORDER BY name, id " ); $stmt->execute([$gameId]); $rows = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $rows[$id] = [ 'id' => $id, 'name' => trim((string)($row['name'] ?? ('Storylet ' . $id))), 'trigger_type' => strtolower(trim((string)($row['trigger_type'] ?? 'manual'))), 'status' => strtolower(trim((string)($row['status'] ?? 'draft'))), ]; } return $rows; } /* |-------------------------------------------------------------------------- | Status Subject Trait Effects | WAYFARER_STATUS_SUBJECT_EFFECTS_AUTHORING_V89 |-------------------------------------------------------------------------- | | Lifecycle Effects address the person who owns the Status rather than an | authored Player slot or Entity. Trait identity is therefore stored by the | same normalized authored-name key used by Party Trait targeting and is | resolved to the subject's actual Trait ID at runtime. | */ function gameplayStatusLifecycleSubjectTraitCatalog( PDO $pdo, int $gameId ): array { $catalog = []; foreach ( gameplayPartyTraitCatalog($pdo, $gameId) as $definition ) { if (!is_array($definition)) { continue; } $name = trim((string)($definition['name'] ?? '')); $dataType = strtolower(trim((string)($definition['data_type'] ?? ''))); if ( $name === '' || !in_array($dataType, ['number', 'boolean'], true) ) { continue; } $targetId = gameplayPartyTraitTargetId($name); $catalog[$targetId] = [ 'target_id' => $targetId, 'name' => $name, 'data_type' => $dataType, ]; } uasort( $catalog, static function (array $a, array $b): int { return strcasecmp( (string)($a['name'] ?? ''), (string)($b['name'] ?? '') ); } ); return $catalog; } function gameplayStatusLifecycleBuildEffects( $postedEffects, array $traitCatalog, bool $recurring, string $context ): array { $postedEffects = is_array($postedEffects) ? array_values($postedEffects) : []; $effects = []; $effectKeys = []; foreach ($postedEffects as $effectIndex => $postedEffect) { if (!is_array($postedEffect)) { continue; } $targetId = trim((string)($postedEffect['target_id'] ?? '')); $operation = strtolower(trim((string)($postedEffect['operation'] ?? ''))); if ($targetId === '' && $operation === '') { continue; } $number = $effectIndex + 1; if ($targetId === '' || !isset($traitCatalog[$targetId])) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' must select a valid Status Subject Trait.', ]; } $definition = $traitCatalog[$targetId]; $dataType = (string)$definition['data_type']; $key = strtolower(trim((string)($postedEffect['key'] ?? ''))); if ($key === '') { try { $key = 'effect_' . bin2hex(random_bytes(6)); } catch (Throwable $exception) { $key = 'effect_' . substr(sha1(uniqid('', true)), 0, 12); } } if (preg_match('/^[a-z][a-z0-9_]{0,47}$/', $key) !== 1) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' has an invalid internal key. Remove and re-add that Effect.', ]; } if (isset($effectKeys[$key])) { return [ 'effects' => [], 'error' => $context . ' has duplicate Effect keys.', ]; } $effectKeys[$key] = true; $allowedOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', 'subtract_percent_current', 'subtract_percent_max', 'add_percent_max', ] : ['set']; if (!in_array($operation, $allowedOperations, true)) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' uses an operation that is not valid for the selected Trait.', ]; } $stored = [ 'key' => $key, 'target_id' => $targetId, 'trait_name' => (string)$definition['name'], 'data_type' => $dataType, 'operation' => $operation, ]; if ($recurring) { $interval = $postedEffect['interval_ticks'] ?? ''; if ( filter_var($interval, FILTER_VALIDATE_INT) === false || (int)$interval < 1 ) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' Every value must be at least 1 tick.', ]; } $stored['interval_ticks'] = (int)$interval; } if ($dataType === 'boolean') { $raw = strtolower(trim((string)($postedEffect['value'] ?? ''))); if (!in_array($raw, ['0', '1', 'true', 'false'], true)) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' must set the selected boolean Trait to True or False.', ]; } $stored['value_mode'] = 'fixed'; $stored['value'] = ($raw === '1' || $raw === 'true') ? 1 : 0; $effects[] = $stored; continue; } $valueMode = strtolower(trim((string)($postedEffect['value_mode'] ?? 'fixed'))); if (!in_array($valueMode, ['fixed', 'random'], true)) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' Value Type must be Fixed or Random Min–Max.', ]; } $percentOperation = in_array( $operation, ['subtract_percent_current', 'subtract_percent_max', 'add_percent_max'], true ); $stored['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim((string)($postedEffect['value_min'] ?? '')); $maxRaw = trim((string)($postedEffect['value_max'] ?? '')); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' Random Min–Max requires numeric values.', ]; } $minimum = (float)$minRaw; $maximum = (float)$maxRaw; if ($minimum > $maximum) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' Random Min cannot be greater than Max.', ]; } if ($percentOperation && $minimum < 0) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' percentage values cannot be negative.', ]; } $stored['value_min'] = gameplayEffectNormalizeNumber($minimum); $stored['value_max'] = gameplayEffectNormalizeNumber($maximum); } else { $valueRaw = trim((string)($postedEffect['value'] ?? '')); if ($valueRaw === '' || !is_numeric($valueRaw)) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' requires a numeric value.', ]; } $value = (float)$valueRaw; if ($percentOperation && $value < 0) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' percentage values cannot be negative.', ]; } $stored['value'] = gameplayEffectNormalizeNumber($value); } $effects[] = $stored; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayBuildStatusLifecycleFromPost( PDO $pdo, int $gameId, $postedLifecycle ): array { $postedLifecycle = is_array($postedLifecycle) ? $postedLifecycle : []; $mode = strtolower(trim((string)($postedLifecycle['mode'] ?? 'manual'))); if (!in_array($mode, ['manual', 'progression'], true)) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Progression Mode must be Manual or Timed Stages.', ]; } if ($mode === 'manual') { return [ 'lifecycle' => null, 'editor' => [ 'mode' => 'manual', 'stages' => [], ], 'error' => '', ]; } $postedStages = isset($postedLifecycle['stages']) && is_array($postedLifecycle['stages']) ? array_values($postedLifecycle['stages']) : []; if ($postedStages === []) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Timed Status progression needs at least one Stage.', ]; } $stageKeys = []; $stages = []; foreach ($postedStages as $stageIndex => $postedStage) { if (!is_array($postedStage)) { continue; } $stageNumber = $stageIndex + 1; $key = gameplayStatusLifecycleNormalizeKey( (string)($postedStage['key'] ?? '') ); if ( $key === '' || preg_match('/^[a-z][a-z0-9_]{0,47}$/', $key) !== 1 ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' has an invalid internal key. Remove and re-add that Stage.', ]; } if (isset($stageKeys[$key])) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage keys must be unique.', ]; } $name = trim((string)($postedStage['name'] ?? '')); if ($name === '') { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' needs a Stage Name.', ]; } $nameLength = function_exists('mb_strlen') ? mb_strlen($name, 'UTF-8') : strlen($name); if ($nameLength > 80) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' name must be 80 characters or fewer.', ]; } $durationMode = strtolower(trim((string)($postedStage['duration_mode'] ?? 'fixed'))); if (!in_array($durationMode, ['fixed', 'random'], true)) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' has an invalid Duration Mode.', ]; } if ($durationMode === 'fixed') { $value = $postedStage['duration_value'] ?? ''; if ( filter_var($value, FILTER_VALIDATE_INT) === false || (int)$value < 1 ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' Fixed Duration must be at least 1 tick.', ]; } $duration = [ 'mode' => 'fixed', 'value' => (int)$value, ]; } else { $min = $postedStage['duration_min'] ?? ''; $max = $postedStage['duration_max'] ?? ''; if ( filter_var($min, FILTER_VALIDATE_INT) === false || filter_var($max, FILTER_VALIDATE_INT) === false || (int)$min < 1 || (int)$max < 1 || (int)$min > (int)$max ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' Random Duration needs whole-number Min/Max ticks of at least 1, with Min no greater than Max.', ]; } $duration = [ 'mode' => 'random', 'min' => (int)$min, 'max' => (int)$max, ]; } $stageKeys[$key] = true; $stages[] = [ 'key' => $key, 'name' => $name, 'duration' => $duration, '_posted_entry_effects' => isset($postedStage['entry_effects']) && is_array($postedStage['entry_effects']) ? array_values($postedStage['entry_effects']) : [], '_posted_tick_effects' => isset($postedStage['tick_effects']) && is_array($postedStage['tick_effects']) ? array_values($postedStage['tick_effects']) : [], '_posted_outcomes' => isset($postedStage['outcomes']) && is_array($postedStage['outcomes']) ? array_values($postedStage['outcomes']) : [], ]; } if ($stages === []) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Timed Status progression needs at least one valid Stage.', ]; } $storylets = gameplayStatusLifecycleStorylets( $pdo, $gameId ); /* WAYFARER_STATUS_SUBJECT_EFFECTS_AUTHORING_V89 */ $subjectTraitCatalog = gameplayStatusLifecycleSubjectTraitCatalog( $pdo, $gameId ); foreach ($stages as $stageIndex => &$stage) { $stageNumber = $stageIndex + 1; $entryEffectResult = gameplayStatusLifecycleBuildEffects( $stage['_posted_entry_effects'] ?? [], $subjectTraitCatalog, false, 'Status Stage ' . $stageNumber . ' On Enter Effects' ); if ($entryEffectResult['error'] !== '') { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => $entryEffectResult['error'], ]; } $tickEffectResult = gameplayStatusLifecycleBuildEffects( $stage['_posted_tick_effects'] ?? [], $subjectTraitCatalog, true, 'Status Stage ' . $stageNumber . ' While in Stage Effects' ); if ($tickEffectResult['error'] !== '') { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => $tickEffectResult['error'], ]; } $stage['entry_effects'] = $entryEffectResult['effects']; $stage['tick_effects'] = $tickEffectResult['effects']; $postedOutcomes = $stage['_posted_outcomes']; unset( $stage['_posted_entry_effects'], $stage['_posted_tick_effects'], $stage['_posted_outcomes'] ); if ($postedOutcomes === []) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' needs at least one Outcome.', ]; } $outcomes = []; $outcomeKeys = []; $chanceTotal = 0; foreach ($postedOutcomes as $outcomeIndex => $postedOutcome) { if (!is_array($postedOutcome)) { continue; } $outcomeNumber = $outcomeIndex + 1; $key = gameplayStatusLifecycleNormalizeKey( (string)($postedOutcome['key'] ?? '') ); if ( $key === '' || preg_match('/^[a-z][a-z0-9_]{0,47}$/', $key) !== 1 ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' has an invalid internal key. Remove and re-add that Outcome.', ]; } if (isset($outcomeKeys[$key])) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' has duplicate Outcome keys.', ]; } $label = trim((string)($postedOutcome['label'] ?? '')); if ($label === '') { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' needs an Outcome Name.', ]; } $chance = $postedOutcome['chance'] ?? ''; if ( filter_var($chance, FILTER_VALIDATE_INT) === false || (int)$chance < 1 || (int)$chance > 100 ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' Chance must be a whole number from 1 to 100.', ]; } $chance = (int)$chance; $chanceTotal += $chance; $result = strtolower(trim((string)($postedOutcome['result'] ?? 'inactive'))); if ( !in_array($result, ['inactive', 'stay'], true) && preg_match('/^stage:([a-z][a-z0-9_]{0,47})$/', $result, $match) !== 1 ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' has an invalid Result.', ]; } if ( strpos($result, 'stage:') === 0 && !isset($stageKeys[substr($result, 6)]) ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' points to a Stage that no longer exists.', ]; } $presentation = strtolower(trim((string)($postedOutcome['presentation'] ?? 'silent'))); if (!in_array($presentation, ['silent', 'storylet'], true)) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' has an invalid Presentation Mode.', ]; } $storyletId = 0; if ($presentation === 'storylet') { $storyletId = (int)($postedOutcome['storylet_id'] ?? 0); if ($storyletId <= 0 || !isset($storylets[$storyletId])) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' must select a root Storylet from this Game.', ]; } } $outcomeEffectResult = gameplayStatusLifecycleBuildEffects( $postedOutcome['effects'] ?? [], $subjectTraitCatalog, false, 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' Effects' ); if ($outcomeEffectResult['error'] !== '') { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => $outcomeEffectResult['error'], ]; } $outcomeKeys[$key] = true; $outcomes[] = [ 'key' => $key, 'label' => $label, 'chance' => $chance, 'result' => $result, 'effects' => $outcomeEffectResult['effects'], 'presentation' => $presentation, 'storylet_id' => $presentation === 'storylet' ? $storyletId : null, ]; } if ($outcomes === []) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' needs at least one valid Outcome.', ]; } if ($chanceTotal !== 100) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' Outcome chances must total exactly 100%. Current total: ' . $chanceTotal . '%.', ]; } $stage['outcomes'] = $outcomes; } unset($stage); $lifecycle = [ 'mode' => 'progression', 'stages' => $stages, ]; return [ 'lifecycle' => $lifecycle, 'editor' => $lifecycle, 'error' => '', ]; } ================================================================================ FILE: _wayfarer_backups/v92-20260930-134321/includes/gameplay/storylet-bindings.php ================================================================================ 'Random Party Member', 'random_possessed_item' => 'Random Possessed Item', ]; } function gameplayStoryletBindingNormalizeKey( string $value ): string { return strtolower( trim($value) ); } function gameplayStoryletBindingNormalizeTraitName( string $value ): string { $value = trim( preg_replace( '/\s+/u', ' ', $value ) ?? $value ); if (function_exists('mb_strtolower')) { return mb_strtolower( $value, 'UTF-8' ); } return strtolower($value); } function gameplayStoryletBindingNamedTraitId( string $traitName ): string { return 'binding_name_' . bin2hex( gameplayStoryletBindingNormalizeTraitName( $traitName ) ); } function gameplayBuildStoryletBindingsFromPost( $postedBindings ): array { $bindings = []; $error = ''; if ($postedBindings === null || $postedBindings === '') { return [ 'bindings' => [], 'error' => '', ]; } if (!is_array($postedBindings)) { return [ 'bindings' => [], 'error' => 'Storylet Bindings must be a list.', ]; } $selectors = gameplayStoryletBindingSelectorOptions(); $seen = []; foreach ( array_values($postedBindings) as $index => $row ) { if (!is_array($row)) { continue; } $key = gameplayStoryletBindingNormalizeKey( (string)( $row['key'] ?? '' ) ); $label = trim( (string)( $row['label'] ?? '' ) ); $selector = strtolower( trim( (string)( $row['selector'] ?? '' ) ) ); /* * Ignore a completely blank newly-added row. */ if ( $key === '' && $label === '' && $selector === '' ) { continue; } $rowNumber = $index + 1; if ( $key === '' || preg_match( '/^[a-z][a-z0-9_]{0,31}$/', $key ) !== 1 ) { $error = 'Binding ' . $rowNumber . ' needs a Key beginning with a letter and containing only lowercase letters, numbers, and underscores (maximum 32 characters).'; break; } if (isset($seen[$key])) { $error = 'Binding Keys must be unique. "' . $key . '" is used more than once.'; break; } if ($label === '') { $error = 'Binding ' . $rowNumber . ' needs a Binding Name.'; break; } $labelLength = function_exists('mb_strlen') ? mb_strlen( $label, 'UTF-8' ) : strlen($label); if ($labelLength > 80) { $error = 'Binding ' . $rowNumber . ' Binding Name must be 80 characters or fewer.'; break; } if (!isset($selectors[$selector])) { $error = 'Binding ' . $rowNumber . ' must use a valid Selection Rule.'; break; } $seen[$key] = true; $bindings[] = [ 'key' => $key, 'label' => $label, 'selector' => $selector, ]; } return [ 'bindings' => $bindings, 'error' => $error, ]; } function gameplayStoryletBindingTraitCatalog( PDO $pdo, int $gameId, string $entityType ): array { $entityType = strtolower( trim($entityType) ); if ( $gameId <= 0 || !in_array( $entityType, ['character', 'item'], true ) ) { return []; } $stmt = $pdo->prepare( " SELECT id, config FROM wf_entities WHERE game_id = ? AND status = 'active' AND LOWER(entity_type) = ? ORDER BY id " ); $stmt->execute([ $gameId, $entityType, ]); $catalog = []; foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $config = json_decode( (string)( $row['config'] ?? '' ), true ); if (!is_array($config)) { $config = []; } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || ( array_key_exists( 'active', $trait ) && !$trait['active'] ) || !empty($trait['system']) ) { continue; } $name = trim( (string)( $trait['name'] ?? '' ) ); if ($name === '') { continue; } $type = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); if ($type === 'binary') { $type = 'boolean'; } if ( !in_array( $type, ['number', 'boolean'], true ) ) { continue; } $normalizedName = gameplayStoryletBindingNormalizeTraitName( $name ); if ($normalizedName === '') { continue; } if (!isset($catalog[$normalizedName])) { $catalog[$normalizedName] = [ 'name' => $name, 'data_type' => $type, 'conflict' => false, ]; continue; } if ( $catalog[$normalizedName]['data_type'] !== $type ) { /* * A random binding cannot safely target one logical Trait name * when different Entities define it with incompatible types. */ $catalog[$normalizedName]['conflict'] = true; } } } foreach ($catalog as $name => $definition) { if (!empty($definition['conflict'])) { unset($catalog[$name]); } } uasort( $catalog, static function ( array $a, array $b ): int { return strcasecmp( (string)$a['name'], (string)$b['name'] ); } ); return $catalog; } function gameplayStoryletBindingEffectTargets( PDO $pdo, int $gameId, array $bindings ): array { $targets = []; /* WAYFARER_PARTY_BINDINGS_V85 */ $partyTraits = function_exists( 'gameplayPartyTraitCatalog' ) ? gameplayPartyTraitCatalog( $pdo, $gameId ) : gameplayStoryletBindingTraitCatalog( $pdo, $gameId, 'character' ); $itemTraits = gameplayStoryletBindingTraitCatalog( $pdo, $gameId, 'item' ); foreach ($bindings as $binding) { if (!is_array($binding)) { continue; } $key = gameplayStoryletBindingNormalizeKey( (string)( $binding['key'] ?? '' ) ); $label = trim( (string)( $binding['label'] ?? $key ) ); $selector = strtolower( trim( (string)( $binding['selector'] ?? '' ) ) ); if ( $key === '' || preg_match( '/^[a-z][a-z0-9_]{0,31}$/', $key ) !== 1 ) { continue; } $scope = 'binding:' . $key; $group = 'Binding — ' . ( $label !== '' ? $label : $key ); if ($selector === 'random_party_character') { $targetId = 'sys_character_party_member'; $targetKey = $scope . ':trait:' . $targetId; $targets[$targetKey] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $targetId, 'target_system_key' => 'party_member', 'trait_type' => 'boolean', 'data_type' => 'boolean', 'semantic_type' => 'storylet_binding_trait', 'condition_operators' => [ 'equals', 'not_equals', ], 'effect_operations' => [ 'set', ], 'supports_random' => false, 'group' => $group, 'label' => 'Party Membership', 'helper' => 'Random Party Member', /* Legacy Entity-only property: keep saved Effects editable. */ 'effect_hidden' => true, 'hierarchy' => [ [ 'key' => 'storylet_binding', 'label' => 'Storylet Binding', ], [ 'key' => $scope, 'label' => $label !== '' ? $label : $key, ], [ 'key' => 'party_membership', 'label' => 'Party Membership', ], ], ]; foreach ( $partyTraits as $trait ) { $namedTargetId = gameplayStoryletBindingNamedTraitId( (string)$trait['name'] ); $targetKey = $scope . ':trait:' . $namedTargetId; $dataType = (string)$trait['data_type']; $targets[$targetKey] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $namedTargetId, 'trait_type' => $dataType, 'data_type' => $dataType, 'semantic_type' => 'storylet_binding_named_trait', 'condition_operators' => $dataType === 'boolean' ? [ 'equals', 'not_equals', ] : wayfarerConditionOperators(), 'effect_operations' => $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ], 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => (string)$trait['name'], 'helper' => 'Random Party Member', 'hierarchy' => [ [ 'key' => 'storylet_binding', 'label' => 'Storylet Binding', ], [ 'key' => $scope, 'label' => $label !== '' ? $label : $key, ], [ 'key' => $namedTargetId, 'label' => (string)$trait['name'], ], ], ]; } /* WAYFARER_STATUS_BINDING_TARGETS_V87 */ if (function_exists('gameplayStatusDefinitions')) { foreach ( gameplayStatusDefinitions( $pdo, $gameId, true ) as $statusDefinition ) { $statusId = (int)($statusDefinition['id'] ?? 0); $statusName = trim((string)($statusDefinition['name'] ?? '')); if ($statusId <= 0 || $statusName === '') { continue; } $statusTargetKey = $scope . ':status:' . $statusId; $targets[$statusTargetKey] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'status', 'target_id' => (string)$statusId, 'trait_type' => 'boolean', 'data_type' => 'boolean', 'semantic_type' => 'status', 'condition_operators' => [ 'equals', 'not_equals', ], 'effect_operations' => [ 'set', ], 'supports_random' => false, 'value_options' => [ '0' => 'Inactive', '1' => 'Active', ], 'group' => $group, 'label' => 'Status — ' . $statusName, 'helper' => 'Persistent Status on the selected Party Member', 'hierarchy' => [ [ 'key' => 'storylet_binding', 'label' => 'Storylet Binding', ], [ 'key' => $scope, 'label' => $label !== '' ? $label : $key, ], [ 'key' => 'status:' . $statusId, 'label' => 'Status — ' . $statusName, ], ], ]; } } continue; } if ($selector === 'random_possessed_item') { $targetId = 'sys_item_quantity'; $targetKey = $scope . ':trait:' . $targetId; $targets[$targetKey] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $targetId, 'target_system_key' => 'quantity', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'storylet_binding_trait', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => [ 'set', 'add', 'subtract', ], 'supports_random' => true, 'group' => $group, 'label' => 'Quantity', 'helper' => 'Random Possessed Item', 'hierarchy' => [ [ 'key' => 'storylet_binding', 'label' => 'Storylet Binding', ], [ 'key' => $scope, 'label' => $label !== '' ? $label : $key, ], [ 'key' => 'quantity', 'label' => 'Quantity', ], ], ]; foreach ( $itemTraits as $trait ) { $namedTargetId = gameplayStoryletBindingNamedTraitId( (string)$trait['name'] ); $targetKey = $scope . ':trait:' . $namedTargetId; $dataType = (string)$trait['data_type']; $targets[$targetKey] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $namedTargetId, 'trait_type' => $dataType, 'data_type' => $dataType, 'semantic_type' => 'storylet_binding_named_trait', 'condition_operators' => $dataType === 'boolean' ? [ 'equals', 'not_equals', ] : wayfarerConditionOperators(), 'effect_operations' => $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ], 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => (string)$trait['name'], 'helper' => 'Random Possessed Item', 'hierarchy' => [ [ 'key' => 'storylet_binding', 'label' => 'Storylet Binding', ], [ 'key' => $scope, 'label' => $label !== '' ? $label : $key, ], [ 'key' => $namedTargetId, 'label' => (string)$trait['name'], ], ], ]; } } } return $targets; } ================================================================================ FILE: _wayfarer_backups/v92-20260930-134321/play/includes/statuses.php ================================================================================ 0; } return in_array( strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on', 'active'], true ); } function wayfarerRuntimeStatusDefinitions( PDO $pdo, int $gameId, bool $activeOnly = true ): array { if ($gameId <= 0) { return []; } $sql = " SELECT id, name, description, config, status FROM wf_statuses WHERE game_id = ? "; if ($activeOnly) { $sql .= " AND status = 'active'"; } $sql .= " ORDER BY name, id"; $stmt = $pdo->prepare($sql); $stmt->execute([$gameId]); $definitions = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $config = []; if (!empty($row['config'])) { $decoded = json_decode((string)$row['config'], true); if (is_array($decoded)) { $config = $decoded; } } $showToPlayer = !array_key_exists('show_to_player', $config) || wayfarerRuntimeStatusNormalizeBoolean($config['show_to_player']); $showStageToPlayer = !array_key_exists('show_stage_to_player', $config) || wayfarerRuntimeStatusNormalizeBoolean($config['show_stage_to_player']); $showTimeRemaining = array_key_exists('show_time_remaining', $config) && wayfarerRuntimeStatusNormalizeBoolean($config['show_time_remaining']); $lifecycle = isset($config['lifecycle']) && is_array($config['lifecycle']) ? $config['lifecycle'] : null; $definitions[(string)$id] = [ 'id' => $id, 'name' => trim((string)($row['name'] ?? ('Status ' . $id))), 'description' => trim((string)($row['description'] ?? '')), 'status' => strtolower(trim((string)($row['status'] ?? 'draft'))), 'show_to_player' => $showToPlayer, 'show_stage_to_player' => $showStageToPlayer, 'show_time_remaining' => $showTimeRemaining, 'lifecycle' => $lifecycle, ]; } return $definitions; } function wayfarerRuntimeStatusDefinition( PDO $pdo, int $gameId, int $statusId, bool $activeOnly = true ): ?array { if ($statusId <= 0) { return null; } $definitions = wayfarerRuntimeStatusDefinitions( $pdo, $gameId, $activeOnly ); return $definitions[(string)$statusId] ?? null; } function wayfarerRuntimeStatusScopeAddress( array $runtimeState, string $scope ): ?array { $scope = strtolower(trim($scope)); if ( preg_match('/^player:([0-9]+)$/', $scope, $match) === 1 ) { $key = (string)((int)$match[1]); if (!isset($runtimeState['players'][$key]) || !is_array($runtimeState['players'][$key])) { return null; } return [ 'bucket' => 'players', 'key' => $key, ]; } if ( preg_match('/^entity:([1-9][0-9]*)$/', $scope, $match) === 1 ) { $key = (string)((int)$match[1]); if (!isset($runtimeState['entities'][$key]) || !is_array($runtimeState['entities'][$key])) { return null; } return [ 'bucket' => 'entities', 'key' => $key, ]; } return null; } function wayfarerRuntimeStatusState( array $runtimeState, string $scope, int $statusId ): array { $address = wayfarerRuntimeStatusScopeAddress( $runtimeState, $scope ); if ($address === null || $statusId <= 0) { return [ 'found_owner' => false, 'active' => false, 'started_tick' => null, 'stage_key' => null, 'stage_started_tick' => null, 'duration_ticks' => null, 'next_check_tick' => null, ]; } $stored = $runtimeState[$address['bucket']][$address['key']]['statuses'][(string)$statusId] ?? null; if (!is_array($stored)) { return [ 'found_owner' => true, 'active' => false, 'started_tick' => null, 'stage_key' => null, 'stage_started_tick' => null, 'duration_ticks' => null, 'next_check_tick' => null, ]; } return [ 'found_owner' => true, 'active' => !empty($stored['active']), 'started_tick' => isset($stored['started_tick']) && is_numeric($stored['started_tick']) ? (int)$stored['started_tick'] : null, 'stage_key' => trim((string)($stored['stage_key'] ?? '')) !== '' ? trim((string)$stored['stage_key']) : null, 'stage_started_tick' => isset($stored['stage_started_tick']) && is_numeric($stored['stage_started_tick']) ? (int)$stored['stage_started_tick'] : null, 'duration_ticks' => isset($stored['duration_ticks']) && is_numeric($stored['duration_ticks']) ? (int)$stored['duration_ticks'] : null, 'next_check_tick' => isset($stored['next_check_tick']) && is_numeric($stored['next_check_tick']) ? (int)$stored['next_check_tick'] : null, ]; } function wayfarerRuntimeSetStatus( array &$runtimeState, string $scope, int $statusId, bool $active ): array { $address = wayfarerRuntimeStatusScopeAddress( $runtimeState, $scope ); if ($address === null || $statusId <= 0) { return [ 'applied' => false, 'before' => null, 'after' => null, 'started_tick' => null, 'error' => 'Status owner was not found in runtime state.', ]; } $bucket = $address['bucket']; $key = $address['key']; if (!isset($runtimeState[$bucket][$key]['statuses']) || !is_array($runtimeState[$bucket][$key]['statuses'])) { $runtimeState[$bucket][$key]['statuses'] = []; } $statusKey = (string)$statusId; $existing = $runtimeState[$bucket][$key]['statuses'][$statusKey] ?? []; if (!is_array($existing)) { $existing = []; } $before = !empty($existing['active']); $startedTick = isset($existing['started_tick']) && is_numeric($existing['started_tick']) ? (int)$existing['started_tick'] : null; if ($active && !$before) { $startedTick = (int)( $runtimeState['runtime']['tick'] ?? 0 ); unset( $existing['stage_key'], $existing['stage_started_tick'], $existing['duration_ticks'], $existing['next_check_tick'], $existing['stage_effect_ticks'], $existing['last_outcome_key'], $existing['last_outcome_tick'] ); } $existing['active'] = $active; $existing['started_tick'] = $startedTick; $runtimeState[$bucket][$key]['statuses'][$statusKey] = $existing; return [ 'applied' => true, 'before' => $before ? 1 : 0, 'after' => $active ? 1 : 0, 'started_tick' => $startedTick, 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Timed lifecycle helpers | WAYFARER_STATUS_LIFECYCLE_RUNTIME_V88 |-------------------------------------------------------------------------- */ function wayfarerRuntimeStatusLifecycleStages( array $definition ): array { $lifecycle = isset($definition['lifecycle']) && is_array($definition['lifecycle']) ? $definition['lifecycle'] : []; if ( strtolower(trim((string)($lifecycle['mode'] ?? 'manual'))) !== 'progression' || !isset($lifecycle['stages']) || !is_array($lifecycle['stages']) ) { return []; } $stages = []; foreach (array_values($lifecycle['stages']) as $stage) { if (!is_array($stage)) { continue; } $key = strtolower(trim((string)($stage['key'] ?? ''))); if ($key === '') { continue; } $stage['key'] = $key; $stages[] = $stage; } return $stages; } function wayfarerRuntimeStatusLifecycleStageMap( array $definition ): array { $map = []; foreach (wayfarerRuntimeStatusLifecycleStages($definition) as $stage) { $map[(string)$stage['key']] = $stage; } return $map; } function wayfarerRuntimeStatusRollDuration( array $stage ): int { $duration = isset($stage['duration']) && is_array($stage['duration']) ? $stage['duration'] : []; $mode = strtolower(trim((string)($duration['mode'] ?? 'fixed'))); if ($mode === 'random') { $min = max(1, (int)($duration['min'] ?? 1)); $max = max($min, (int)($duration['max'] ?? $min)); return random_int($min, $max); } return max(1, (int)($duration['value'] ?? 1)); } function wayfarerRuntimeStatusInitializeLifecycle( array &$runtimeState, string $scope, int $statusId, array $definition ): array { $address = wayfarerRuntimeStatusScopeAddress($runtimeState, $scope); $stages = wayfarerRuntimeStatusLifecycleStages($definition); if ($address === null || $statusId <= 0 || $stages === []) { return [ 'initialized' => false, 'error' => '', ]; } $bucket = $address['bucket']; $key = $address['key']; $statusKey = (string)$statusId; $stored = $runtimeState[$bucket][$key]['statuses'][$statusKey] ?? null; if (!is_array($stored) || empty($stored['active'])) { return [ 'initialized' => false, 'error' => '', ]; } $stageMap = wayfarerRuntimeStatusLifecycleStageMap($definition); $stageKey = strtolower(trim((string)($stored['stage_key'] ?? ''))); $entered = false; if ($stageKey === '' || !isset($stageMap[$stageKey])) { $entered = true; $stageKey = (string)$stages[0]['key']; $stored['stage_key'] = $stageKey; $stored['stage_started_tick'] = isset($stored['started_tick']) && is_numeric($stored['started_tick']) ? (int)$stored['started_tick'] : (int)($runtimeState['runtime']['tick'] ?? 0); unset( $stored['duration_ticks'], $stored['next_check_tick'], $stored['stage_effect_ticks'] ); } if ( !isset($stored['stage_started_tick']) || !is_numeric($stored['stage_started_tick']) ) { $stored['stage_started_tick'] = (int)( $runtimeState['runtime']['tick'] ?? 0 ); } if ( !isset($stored['duration_ticks']) || !is_numeric($stored['duration_ticks']) || (int)$stored['duration_ticks'] < 1 ) { $stored['duration_ticks'] = wayfarerRuntimeStatusRollDuration( $stageMap[$stageKey] ); } if ( !isset($stored['next_check_tick']) || !is_numeric($stored['next_check_tick']) ) { $stored['next_check_tick'] = (int)$stored['stage_started_tick'] + (int)$stored['duration_ticks']; } $runtimeState[$bucket][$key]['statuses'][$statusKey] = $stored; return [ 'initialized' => true, 'entered' => $entered, 'stage_key' => $stageKey, 'stage_started_tick' => (int)$stored['stage_started_tick'], 'duration_ticks' => (int)$stored['duration_ticks'], 'next_check_tick' => (int)$stored['next_check_tick'], 'error' => '', ]; } function wayfarerRuntimeStatusEnterStage( array &$runtimeState, string $scope, int $statusId, array $definition, string $stageKey ): array { $address = wayfarerRuntimeStatusScopeAddress($runtimeState, $scope); $stageMap = wayfarerRuntimeStatusLifecycleStageMap($definition); $stageKey = strtolower(trim($stageKey)); if ( $address === null || $statusId <= 0 || $stageKey === '' || !isset($stageMap[$stageKey]) ) { return [ 'applied' => false, 'error' => 'Status Stage could not be resolved.', ]; } $bucket = $address['bucket']; $key = $address['key']; $statusKey = (string)$statusId; $stored = $runtimeState[$bucket][$key]['statuses'][$statusKey] ?? []; if (!is_array($stored)) { $stored = []; } $tick = (int)($runtimeState['runtime']['tick'] ?? 0); $duration = wayfarerRuntimeStatusRollDuration($stageMap[$stageKey]); $stored['active'] = true; if (!isset($stored['started_tick']) || !is_numeric($stored['started_tick'])) { $stored['started_tick'] = $tick; } $stored['stage_key'] = $stageKey; $stored['stage_started_tick'] = $tick; $stored['duration_ticks'] = $duration; $stored['next_check_tick'] = $tick + $duration; $stored['stage_effect_ticks'] = []; $runtimeState[$bucket][$key]['statuses'][$statusKey] = $stored; return [ 'applied' => true, 'stage_key' => $stageKey, 'stage_started_tick' => $tick, 'duration_ticks' => $duration, 'next_check_tick' => $tick + $duration, 'error' => '', ]; } function wayfarerRuntimeStatusChooseOutcome( array $stage ): ?array { $outcomes = isset($stage['outcomes']) && is_array($stage['outcomes']) ? array_values($stage['outcomes']) : []; if ($outcomes === []) { return null; } $roll = random_int(1, 100); $cursor = 0; foreach ($outcomes as $outcome) { if (!is_array($outcome)) { continue; } $chance = max(0, (int)($outcome['chance'] ?? 0)); $cursor += $chance; if ($roll <= $cursor) { $outcome['_runtime_roll'] = $roll; return $outcome; } } return null; } /* |-------------------------------------------------------------------------- | Status Subject Effects | WAYFARER_STATUS_SUBJECT_EFFECTS_RUNTIME_V89 |-------------------------------------------------------------------------- */ function wayfarerRuntimeStatusResolveSubjectTrait( array $runtimeState, array $traitDefinitions, string $scope, string $logicalTargetId ): array { if (!function_exists('wayfarerRuntimePartyDecodeTraitTargetId') || !function_exists('wayfarerRuntimePartyFindTraitDefinition')) { return [ 'found' => false, 'error' => 'Status Subject Trait resolver is unavailable.', ]; } $normalizedName = wayfarerRuntimePartyDecodeTraitTargetId( $logicalTargetId ); if ($normalizedName === null) { return [ 'found' => false, 'error' => 'Status Subject Trait target is invalid.', ]; } $definitions = []; $bucket = ''; $key = ''; if (preg_match('/^player:([0-9]+)$/', $scope, $match) === 1) { $bucket = 'players'; $key = (string)((int)$match[1]); $definitions = isset($traitDefinitions['player']) && is_array($traitDefinitions['player']) ? $traitDefinitions['player'] : []; } elseif (preg_match('/^entity:([1-9][0-9]*)$/', $scope, $match) === 1) { $bucket = 'entities'; $key = (string)((int)$match[1]); $definitions = isset($traitDefinitions['entities'][$key]) && is_array($traitDefinitions['entities'][$key]) ? $traitDefinitions['entities'][$key] : []; } else { return [ 'found' => false, 'error' => 'Status Subject scope is not a character.', ]; } $match = wayfarerRuntimePartyFindTraitDefinition( $definitions, $normalizedName ); if (!is_array($match)) { return [ 'found' => false, 'error' => 'Status Subject does not expose the selected Trait.', ]; } $traitId = trim((string)($match['id'] ?? '')); $definition = isset($match['definition']) && is_array($match['definition']) ? $match['definition'] : null; if ( $traitId === '' || !isset($runtimeState[$bucket][$key]['traits']) || !is_array($runtimeState[$bucket][$key]['traits']) || !array_key_exists($traitId, $runtimeState[$bucket][$key]['traits']) ) { return [ 'found' => false, 'error' => 'Status Subject runtime Trait was not found.', ]; } return [ 'found' => true, 'trait_id' => $traitId, 'definition' => $definition, 'before' => $runtimeState[$bucket][$key]['traits'][$traitId], 'error' => '', ]; } function wayfarerRuntimeStatusApplySubjectEffect( array &$runtimeState, array $effect, string $scope, int $currentRegionId, array $traitDefinitions ): array { $targetId = trim((string)($effect['target_id'] ?? '')); $operation = strtolower(trim((string)($effect['operation'] ?? ''))); $resolution = wayfarerRuntimeStatusResolveSubjectTrait( $runtimeState, $traitDefinitions, $scope, $targetId ); if (empty($resolution['found'])) { return [ 'scope' => $scope, 'trait_name' => (string)($effect['trait_name'] ?? ''), 'logical_target_id' => $targetId, 'operation' => $operation, 'applied' => false, 'error' => (string)($resolution['error'] ?? 'Status Subject Trait was not found.'), ]; } $rolled = function_exists('wayfarerRuntimeRollEffectValue') ? wayfarerRuntimeRollEffectValue($effect) : [ 'valid' => true, 'value' => $effect['value'] ?? 0, 'mode' => 'fixed', 'error' => '', ]; if (empty($rolled['valid'])) { return [ 'scope' => $scope, 'trait_name' => (string)($effect['trait_name'] ?? ''), 'logical_target_id' => $targetId, 'operation' => $operation, 'applied' => false, 'error' => (string)($rolled['error'] ?? 'Status Effect value could not be resolved.'), ]; } $authoredValue = $rolled['value'] ?? 0; $resolvedOperation = $operation; $resolvedValue = $authoredValue; $definition = isset($resolution['definition']) && is_array($resolution['definition']) ? $resolution['definition'] : null; if ( in_array( $operation, ['subtract_percent_current', 'subtract_percent_max', 'add_percent_max'], true ) ) { if (!is_numeric($authoredValue)) { return [ 'scope' => $scope, 'trait_name' => (string)($effect['trait_name'] ?? ''), 'logical_target_id' => $targetId, 'operation' => $operation, 'applied' => false, 'error' => 'Status percentage Effect requires a numeric percentage.', ]; } $percentage = (float)$authoredValue; if ($percentage < 0) { return [ 'scope' => $scope, 'trait_name' => (string)($effect['trait_name'] ?? ''), 'logical_target_id' => $targetId, 'operation' => $operation, 'applied' => false, 'error' => 'Status percentage Effect cannot use a negative percentage.', ]; } if ($operation === 'subtract_percent_current') { if (!is_numeric($resolution['before'] ?? null)) { return [ 'scope' => $scope, 'trait_name' => (string)($effect['trait_name'] ?? ''), 'logical_target_id' => $targetId, 'operation' => $operation, 'applied' => false, 'error' => 'Subtract % of Current requires a numeric current Trait value.', ]; } $resolvedOperation = 'subtract'; $resolvedValue = wayfarerNormalizeRuntimeNumber( ((float)$resolution['before'] * $percentage) / 100 ); } else { $maximum = is_array($definition) ? ($definition['max'] ?? null) : null; if (!is_numeric($maximum)) { return [ 'scope' => $scope, 'trait_name' => (string)($effect['trait_name'] ?? ''), 'logical_target_id' => $targetId, 'operation' => $operation, 'applied' => false, 'error' => 'Percentage of Maximum requires the Status Subject Trait to have a numeric Maximum.', ]; } $resolvedOperation = $operation === 'add_percent_max' ? 'add' : 'subtract'; $resolvedValue = wayfarerNormalizeRuntimeNumber( ((float)$maximum * $percentage) / 100 ); } } $runtimeEffect = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => (string)$resolution['trait_id'], 'operation' => $resolvedOperation, 'value' => $resolvedValue, ]; $report = wayfarerApplyRuntimeEffect( $runtimeState, $runtimeEffect, $currentRegionId, $traitDefinitions ); $report['status_effect_key'] = (string)($effect['key'] ?? ''); $report['status_subject_scope'] = $scope; $report['trait_name'] = (string)($effect['trait_name'] ?? ''); $report['logical_target_id'] = $targetId; $report['authored_operation'] = $operation; $report['authored_value'] = $authoredValue; $report['resolved_operation'] = $resolvedOperation; $report['resolved_value'] = $resolvedValue; return $report; } function wayfarerRuntimeStatusApplySubjectEffects( array &$runtimeState, array $effects, string $scope, int $currentRegionId, array $traitDefinitions ): array { $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { continue; } $reports[] = wayfarerRuntimeStatusApplySubjectEffect( $runtimeState, $effect, $scope, $currentRegionId, $traitDefinitions ); } return $reports; } function wayfarerRuntimeStatusApplyRecurringEffects( array &$runtimeState, string $scope, int $statusId, array $stage, int $tick, int $currentRegionId, array $traitDefinitions ): array { $effects = isset($stage['tick_effects']) && is_array($stage['tick_effects']) ? array_values($stage['tick_effects']) : []; if ($effects === []) { return []; } $address = wayfarerRuntimeStatusScopeAddress($runtimeState, $scope); if ($address === null) { return []; } $statusKey = (string)$statusId; $stored =& $runtimeState[$address['bucket']][$address['key']]['statuses'][$statusKey]; if (!is_array($stored) || empty($stored['active'])) { unset($stored); return []; } $stageStartedTick = isset($stored['stage_started_tick']) && is_numeric($stored['stage_started_tick']) ? (int)$stored['stage_started_tick'] : $tick; $elapsed = $tick - $stageStartedTick; if ($elapsed <= 0) { unset($stored); return []; } if (!isset($stored['stage_effect_ticks']) || !is_array($stored['stage_effect_ticks'])) { $stored['stage_effect_ticks'] = []; } $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { continue; } $interval = max(1, (int)($effect['interval_ticks'] ?? 1)); if ($elapsed % $interval !== 0) { continue; } $effectKey = trim((string)($effect['key'] ?? '')); if ($effectKey === '') { continue; } if ( isset($stored['stage_effect_ticks'][$effectKey]) && (int)$stored['stage_effect_ticks'][$effectKey] === $tick ) { continue; } $report = wayfarerRuntimeStatusApplySubjectEffect( $runtimeState, $effect, $scope, $currentRegionId, $traitDefinitions ); $report['interval_ticks'] = $interval; $report['elapsed_stage_ticks'] = $elapsed; $reports[] = $report; $stored['stage_effect_ticks'][$effectKey] = $tick; } unset($stored); return $reports; } function wayfarerRuntimeQueueStatusStorylet( array &$runtimeState, array $event ): void { if (!isset($runtimeState['runtime']) || !is_array($runtimeState['runtime'])) { $runtimeState['runtime'] = []; } if ( !isset($runtimeState['runtime']['pending_status_storylets']) || !is_array($runtimeState['runtime']['pending_status_storylets']) ) { $runtimeState['runtime']['pending_status_storylets'] = []; } $runtimeState['runtime']['pending_status_storylets'][] = $event; } function wayfarerRuntimePromotePendingStatusStorylet( PDO $pdo, int $gameId, array &$runtimeState ): ?array { if ( isset($runtimeState['runtime']['presentation']) && is_array($runtimeState['runtime']['presentation']) ) { return null; } if ( !isset($runtimeState['runtime']['pending_status_storylets']) || !is_array($runtimeState['runtime']['pending_status_storylets']) || $runtimeState['runtime']['pending_status_storylets'] === [] ) { return null; } while ($runtimeState['runtime']['pending_status_storylets'] !== []) { $event = array_shift( $runtimeState['runtime']['pending_status_storylets'] ); if (!is_array($event)) { continue; } $storyletId = (int)($event['storylet_id'] ?? 0); $subjectScope = strtolower(trim((string)($event['subject_scope'] ?? ''))); if ($storyletId <= 0 || $subjectScope === '') { continue; } try { $storylet = wayfarerRuntimeLoadStorylet( $pdo, $gameId, $storyletId ); if (!$storylet) { throw new RuntimeException( 'Status Outcome Storylet could not be loaded.' ); } $subjectResult = wayfarerRuntimeStoryletPresetCharacterBinding( $pdo, $gameId, $storylet, $runtimeState, $subjectScope, 'subject', 'Subject' ); if (($subjectResult['error'] ?? '') !== '') { throw new RuntimeException( (string)$subjectResult['error'] ); } $binding = $subjectResult['binding'] ?? null; if (!is_array($binding)) { throw new RuntimeException( 'Status Outcome Storylet subject could not be prepared.' ); } $result = wayfarerRuntimeEnterTriggeredStoryletInState( $pdo, $gameId, $storyletId, $runtimeState, [ 'subject' => $binding, ], 'status' ); $runtimeState['runtime']['last_status_storylet'] = [ 'storylet_id' => $storyletId, 'subject_scope' => $subjectScope, 'status_id' => (int)($event['status_id'] ?? 0), 'outcome_key' => (string)($event['outcome_key'] ?? ''), 'tick' => (int)($runtimeState['runtime']['tick'] ?? 0), ]; if ($runtimeState['runtime']['pending_status_storylets'] === []) { unset($runtimeState['runtime']['pending_status_storylets']); } return $result; } catch (Throwable $exception) { $runtimeState['runtime']['last_status_storylet_error'] = [ 'storylet_id' => $storyletId, 'subject_scope' => $subjectScope, 'message' => $exception->getMessage(), 'tick' => (int)($runtimeState['runtime']['tick'] ?? 0), ]; } } unset($runtimeState['runtime']['pending_status_storylets']); return null; } function wayfarerRuntimeProcessStatusProgression( PDO $pdo, int $gameId, array &$runtimeState ): array { $definitions = wayfarerRuntimeStatusDefinitions( $pdo, $gameId, true ); if ($definitions === []) { return []; } $tick = (int)($runtimeState['runtime']['tick'] ?? 0); $currentRegionId = (int)($runtimeState['navigation']['region_id'] ?? 0); $traitDefinitions = function_exists('wayfarerRuntimeLoadTickTraitDefinitions') ? wayfarerRuntimeLoadTickTraitDefinitions($pdo, $gameId, []) : []; $reports = []; $owners = []; foreach ( isset($runtimeState['players']) && is_array($runtimeState['players']) ? $runtimeState['players'] : [] as $playerKey => $playerState ) { if (is_array($playerState)) { $owners[] = 'player:' . (string)$playerKey; } } foreach ( isset($runtimeState['entities']) && is_array($runtimeState['entities']) ? $runtimeState['entities'] : [] as $entityKey => $entityState ) { if (is_array($entityState)) { $owners[] = 'entity:' . (string)$entityKey; } } foreach ($owners as $scope) { $address = wayfarerRuntimeStatusScopeAddress($runtimeState, $scope); if ($address === null) { continue; } $storedStatuses = $runtimeState[$address['bucket']][$address['key']]['statuses'] ?? []; if (!is_array($storedStatuses) || $storedStatuses === []) { continue; } foreach (array_keys($storedStatuses) as $statusKey) { $statusId = (int)$statusKey; $definition = $definitions[(string)$statusId] ?? null; if (!is_array($definition)) { continue; } if (wayfarerRuntimeStatusLifecycleStages($definition) === []) { continue; } $init = wayfarerRuntimeStatusInitializeLifecycle( $runtimeState, $scope, $statusId, $definition ); if (empty($init['initialized'])) { continue; } $state = wayfarerRuntimeStatusState( $runtimeState, $scope, $statusId ); if (empty($state['active'])) { continue; } $stageMap = wayfarerRuntimeStatusLifecycleStageMap($definition); $stageKey = (string)($state['stage_key'] ?? ''); $stage = $stageMap[$stageKey] ?? null; if (!is_array($stage)) { continue; } $entryEffectReports = []; if (!empty($init['entered'])) { $entryEffectReports = wayfarerRuntimeStatusApplySubjectEffects( $runtimeState, isset($stage['entry_effects']) && is_array($stage['entry_effects']) ? array_values($stage['entry_effects']) : [], $scope, $currentRegionId, $traitDefinitions ); } $tickEffectReports = wayfarerRuntimeStatusApplyRecurringEffects( $runtimeState, $scope, $statusId, $stage, $tick, $currentRegionId, $traitDefinitions ); if ( $state['next_check_tick'] === null || (int)$state['next_check_tick'] > $tick ) { if ($entryEffectReports !== [] || $tickEffectReports !== []) { $reports[] = [ 'event' => 'stage_effects', 'scope' => $scope, 'status_id' => $statusId, 'status_name' => (string)($definition['name'] ?? ''), 'stage_key' => $stageKey, 'stage_name' => (string)($stage['name'] ?? $stageKey), 'entry_effects' => $entryEffectReports, 'tick_effects' => $tickEffectReports, 'tick' => $tick, ]; } continue; } $outcome = wayfarerRuntimeStatusChooseOutcome($stage); if (!is_array($outcome)) { continue; } /* Outcome Effects apply before the Result changes the Status Stage. */ $outcomeEffectReports = wayfarerRuntimeStatusApplySubjectEffects( $runtimeState, isset($outcome['effects']) && is_array($outcome['effects']) ? array_values($outcome['effects']) : [], $scope, $currentRegionId, $traitDefinitions ); $result = strtolower(trim((string)($outcome['result'] ?? 'inactive'))); $resultError = ''; $nextEntryEffectReports = []; if ($result === 'inactive') { $statusResult = wayfarerRuntimeSetStatus( $runtimeState, $scope, $statusId, false ); $resultError = (string)($statusResult['error'] ?? ''); } elseif ($result === 'stay') { $stageResult = wayfarerRuntimeStatusEnterStage( $runtimeState, $scope, $statusId, $definition, $stageKey ); $resultError = (string)($stageResult['error'] ?? ''); if ($resultError === '') { $nextEntryEffectReports = wayfarerRuntimeStatusApplySubjectEffects( $runtimeState, isset($stage['entry_effects']) && is_array($stage['entry_effects']) ? array_values($stage['entry_effects']) : [], $scope, $currentRegionId, $traitDefinitions ); } } elseif (strpos($result, 'stage:') === 0) { $nextStageKey = substr($result, 6); $stageResult = wayfarerRuntimeStatusEnterStage( $runtimeState, $scope, $statusId, $definition, $nextStageKey ); $resultError = (string)($stageResult['error'] ?? ''); if ($resultError === '' && isset($stageMap[$nextStageKey])) { $nextStage = $stageMap[$nextStageKey]; $nextEntryEffectReports = wayfarerRuntimeStatusApplySubjectEffects( $runtimeState, isset($nextStage['entry_effects']) && is_array($nextStage['entry_effects']) ? array_values($nextStage['entry_effects']) : [], $scope, $currentRegionId, $traitDefinitions ); } } else { $resultError = 'Status Outcome Result is invalid.'; } if ($resultError !== '') { throw new RuntimeException( 'Status progression failed for ' . (string)($definition['name'] ?? ('Status ' . $statusId)) . ': ' . $resultError ); } $afterAddress = wayfarerRuntimeStatusScopeAddress($runtimeState, $scope); if ($afterAddress !== null) { $afterStored =& $runtimeState[$afterAddress['bucket']][$afterAddress['key']]['statuses'][(string)$statusId]; if (is_array($afterStored)) { $afterStored['last_outcome_key'] = (string)($outcome['key'] ?? ''); $afterStored['last_outcome_tick'] = $tick; } unset($afterStored); } $presentation = strtolower(trim((string)($outcome['presentation'] ?? 'silent'))); $storyletId = (int)($outcome['storylet_id'] ?? 0); if ($presentation === 'storylet' && $storyletId > 0) { wayfarerRuntimeQueueStatusStorylet( $runtimeState, [ 'storylet_id' => $storyletId, 'subject_scope' => $scope, 'status_id' => $statusId, 'status_name' => (string)($definition['name'] ?? ''), 'stage_key' => $stageKey, 'stage_name' => (string)($stage['name'] ?? $stageKey), 'outcome_key' => (string)($outcome['key'] ?? ''), 'outcome_label' => (string)($outcome['label'] ?? ''), 'tick' => $tick, ] ); } $reports[] = [ 'event' => 'outcome', 'scope' => $scope, 'status_id' => $statusId, 'status_name' => (string)($definition['name'] ?? ''), 'stage_key' => $stageKey, 'stage_name' => (string)($stage['name'] ?? $stageKey), 'entry_effects' => $entryEffectReports, 'tick_effects' => $tickEffectReports, 'outcome_effects' => $outcomeEffectReports, 'next_stage_entry_effects' => $nextEntryEffectReports, 'outcome_key' => (string)($outcome['key'] ?? ''), 'outcome_label' => (string)($outcome['label'] ?? ''), 'result' => $result, 'roll' => (int)($outcome['_runtime_roll'] ?? 0), 'presentation' => $presentation, 'storylet_id' => $storyletId, 'tick' => $tick, ]; } } if ($reports !== []) { if (!isset($runtimeState['runtime']) || !is_array($runtimeState['runtime'])) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_status_progression'] = [ 'tick' => $tick, 'reports' => $reports, ]; } return $reports; } function wayfarerRuntimeVisibleStatusesForScope( PDO $pdo, int $gameId, array $runtimeState, string $scope ): array { $address = wayfarerRuntimeStatusScopeAddress( $runtimeState, $scope ); if ($address === null) { return []; } $definitions = wayfarerRuntimeStatusDefinitions( $pdo, $gameId, true ); if ($definitions === []) { return []; } $storedStatuses = $runtimeState[$address['bucket']][$address['key']]['statuses'] ?? []; if (!is_array($storedStatuses)) { return []; } $visible = []; $currentTick = (int)($runtimeState['runtime']['tick'] ?? 0); foreach ($definitions as $statusKey => $definition) { if (empty($definition['show_to_player'])) { continue; } $stored = $storedStatuses[$statusKey] ?? null; if (!is_array($stored) || empty($stored['active'])) { continue; } $stageName = ''; $remainingTicks = null; if (wayfarerRuntimeStatusLifecycleStages($definition) !== []) { $stageMap = wayfarerRuntimeStatusLifecycleStageMap($definition); $stageKey = strtolower(trim((string)($stored['stage_key'] ?? ''))); if ($stageKey === '') { $stages = wayfarerRuntimeStatusLifecycleStages($definition); $stageKey = (string)($stages[0]['key'] ?? ''); } if ( !empty($definition['show_stage_to_player']) && isset($stageMap[$stageKey]) ) { $stageName = trim((string)($stageMap[$stageKey]['name'] ?? $stageKey)); } if ( !empty($definition['show_time_remaining']) && isset($stored['next_check_tick']) && is_numeric($stored['next_check_tick']) ) { $remainingTicks = max( 0, (int)$stored['next_check_tick'] - $currentTick ); } } $visible[] = [ 'id' => (int)$definition['id'], 'name' => (string)$definition['name'], 'description' => (string)$definition['description'], 'started_tick' => isset($stored['started_tick']) && is_numeric($stored['started_tick']) ? (int)$stored['started_tick'] : null, 'stage_name' => $stageName, 'remaining_ticks' => $remainingTicks, ]; } return $visible; } ================================================================================ FILE: _wayfarer_backups/v92-20260930-134321/play/includes/storylet-bindings.php ================================================================================ $key, 'label' => $label !== '' ? $label : $key, 'selector' => $selector, ]; } return $bindings; } function wayfarerRuntimeStoryletBindingRequiredTargets( array $storylet, string $bindingKey ): array { $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); $effects = isset($config['effects']) && is_array($config['effects']) ? $config['effects'] : []; $scope = 'binding:' . $bindingKey; $required = []; foreach ($effects as $effect) { if (!is_array($effect)) { continue; } if ( strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ) !== $scope ) { continue; } $targetType = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); if ( $targetType === '' || $targetId === '' ) { continue; } $required[ $targetType . ':' . $targetId ] = [ 'target_type' => $targetType, 'target_id' => $targetId, ]; } return array_values($required); } function wayfarerRuntimeStoryletBindingFindNamedTrait( array $config, string $normalizedName ): ?array { $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; $match = null; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) || ( array_key_exists( 'active', $trait ) && !$trait['active'] ) ) { continue; } $name = wayfarerRuntimeStoryletBindingNormalizeTraitName( (string)( $trait['name'] ?? '' ) ); if ($name !== $normalizedName) { continue; } /* * Ambiguous duplicate names on one Entity are not safe for a generic * binding target. */ if ($match !== null) { return null; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { return null; } $match = $trait; } return $match; } function wayfarerRuntimeStoryletBindingEntitySupportsTargets( array $entity, array $requiredTargets ): array { $config = wayfarerEntityDecodeConfig( $entity['config'] ?? null ); $targetMap = []; foreach ($requiredTargets as $required) { if (!is_array($required)) { continue; } $targetType = strtolower( trim( (string)( $required['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $required['target_id'] ?? '' ) ); if ( $targetType === 'status' && preg_match('/^[1-9][0-9]*$/', $targetId) === 1 ) { $targetMap[$targetId] = $targetId; continue; } if ( $targetType !== 'trait' || $targetId === '' ) { return [ 'supported' => false, 'target_map' => [], ]; } if ( $targetId === 'sys_item_quantity' || $targetId === 'sys_character_party_member' ) { $targetMap[$targetId] = $targetId; continue; } $namedTrait = wayfarerRuntimeStoryletBindingDecodeNamedTraitId( $targetId ); if ($namedTrait === null) { return [ 'supported' => false, 'target_map' => [], ]; } $trait = wayfarerRuntimeStoryletBindingFindNamedTrait( $config, $namedTrait ); if (!is_array($trait)) { return [ 'supported' => false, 'target_map' => [], ]; } $actualTraitId = trim( (string)( $trait['id'] ?? '' ) ); if ($actualTraitId === '') { return [ 'supported' => false, 'target_map' => [], ]; } $targetMap[$targetId] = $actualTraitId; } return [ 'supported' => true, 'target_map' => $targetMap, ]; } /* WAYFARER_PARTY_BINDINGS_V85 */ function wayfarerRuntimeStoryletBindingFindPlayerTrait( array $gameConfig, string $normalizedName ): ?array { $traits = isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : []; $match = null; foreach ($traits as $trait) { if ( !is_array($trait) || ( array_key_exists('active', $trait) && !$trait['active'] ) ) { continue; } $name = wayfarerRuntimeStoryletBindingNormalizeTraitName( (string)( $trait['name'] ?? '' ) ); if ($name !== $normalizedName) { continue; } if ($match !== null) { return null; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { return null; } $match = $trait; } return $match; } function wayfarerRuntimeStoryletBindingPlayerSupportsTargets( array $gameConfig, array $playerState, array $requiredTargets ): array { $targetMap = []; $runtimeTraits = isset($playerState['traits']) && is_array($playerState['traits']) ? $playerState['traits'] : []; foreach ($requiredTargets as $required) { if (!is_array($required)) { continue; } $targetType = strtolower( trim( (string)( $required['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $required['target_id'] ?? '' ) ); if ( $targetType === 'status' && preg_match('/^[1-9][0-9]*$/', $targetId) === 1 ) { $targetMap[$targetId] = $targetId; continue; } if ( $targetType !== 'trait' || $targetId === '' || $targetId === 'sys_item_quantity' || $targetId === 'sys_character_party_member' ) { return [ 'supported' => false, 'target_map' => [], ]; } $namedTrait = wayfarerRuntimeStoryletBindingDecodeNamedTraitId( $targetId ); if ($namedTrait === null) { return [ 'supported' => false, 'target_map' => [], ]; } $trait = wayfarerRuntimeStoryletBindingFindPlayerTrait( $gameConfig, $namedTrait ); if (!is_array($trait)) { return [ 'supported' => false, 'target_map' => [], ]; } $actualTraitId = trim( (string)( $trait['id'] ?? '' ) ); if ( $actualTraitId === '' || !array_key_exists( $actualTraitId, $runtimeTraits ) ) { return [ 'supported' => false, 'target_map' => [], ]; } $targetMap[$targetId] = $actualTraitId; } return [ 'supported' => true, 'target_map' => $targetMap, ]; } /* WAYFARER_STATUS_SUBJECT_BINDING_V88 */ function wayfarerRuntimeStoryletPresetCharacterBinding( PDO $pdo, int $gameId, array $storylet, array $runtimeState, string $runtimeScope, string $bindingKey = 'subject', string $label = 'Subject' ): array { $runtimeScope = strtolower(trim($runtimeScope)); $bindingKey = wayfarerRuntimeStoryletBindingNormalizeKey($bindingKey); if ( $bindingKey === '' || preg_match('/^[a-z][a-z0-9_]{0,31}$/', $bindingKey) !== 1 ) { return [ 'binding' => null, 'error' => 'Triggered Storylet subject binding key is invalid.', ]; } $requiredTargets = wayfarerRuntimeStoryletBindingRequiredTargets( $storylet, $bindingKey ); if ( preg_match('/^player:([0-9]+)$/', $runtimeScope, $match) === 1 ) { $slot = (int)$match[1]; $playerState = $runtimeState['players'][(string)$slot] ?? null; if (!is_array($playerState)) { return [ 'binding' => null, 'error' => 'Triggered Storylet Player subject was not found.', ]; } $gameConfig = function_exists('wayfarerRuntimeLoadGameConfig') ? wayfarerRuntimeLoadGameConfig($pdo, $gameId) : []; $support = wayfarerRuntimeStoryletBindingPlayerSupportsTargets( $gameConfig, $playerState, $requiredTargets ); if (empty($support['supported'])) { return [ 'binding' => null, 'error' => 'Triggered Storylet subject does not expose every Trait/Status required by the subject Binding Effects.', ]; } $name = trim((string)($playerState['name'] ?? '')); if ($name === '') { $name = 'Player ' . ($slot + 1); } return [ 'binding' => [ 'key' => $bindingKey, 'label' => $label, 'selector' => 'status_subject', 'runtime_scope' => 'player:' . $slot, 'subject_kind' => 'player', 'player_slot' => $slot, 'entity_id' => 0, 'entity_type' => 'player', 'name' => $name, 'target_map' => $support['target_map'] ?? [], ], 'error' => '', ]; } if ( preg_match('/^entity:([1-9][0-9]*)$/', $runtimeScope, $match) === 1 ) { $entityId = (int)$match[1]; $runtimeEntity = $runtimeState['entities'][(string)$entityId] ?? null; if (!is_array($runtimeEntity)) { return [ 'binding' => null, 'error' => 'Triggered Storylet Character subject was not found.', ]; } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $entityId, $gameId, ]); $entity = $stmt->fetch(PDO::FETCH_ASSOC); if (!$entity) { return [ 'binding' => null, 'error' => 'Triggered Storylet Character definition was not found.', ]; } $support = wayfarerRuntimeStoryletBindingEntitySupportsTargets( $entity, $requiredTargets ); if (empty($support['supported'])) { return [ 'binding' => null, 'error' => 'Triggered Storylet subject does not expose every Trait/Status required by the subject Binding Effects.', ]; } return [ 'binding' => [ 'key' => $bindingKey, 'label' => $label, 'selector' => 'status_subject', 'runtime_scope' => 'entity:' . $entityId, 'subject_kind' => 'entity', 'player_slot' => null, 'entity_id' => $entityId, 'entity_type' => strtolower(trim((string)($entity['entity_type'] ?? 'character'))), 'name' => trim((string)($entity['name'] ?? ('Character ' . $entityId))), 'target_map' => $support['target_map'] ?? [], ], 'error' => '', ]; } return [ 'binding' => null, 'error' => 'Triggered Storylet subject scope is invalid.', ]; } function wayfarerRuntimeStoryletBindingCandidates( PDO $pdo, int $gameId, array $storylet, array $binding, array $runtimeState ): array { $selector = strtolower( trim( (string)( $binding['selector'] ?? '' ) ) ); $bindingKey = wayfarerRuntimeStoryletBindingNormalizeKey( (string)( $binding['key'] ?? '' ) ); $requiredTargets = wayfarerRuntimeStoryletBindingRequiredTargets( $storylet, $bindingKey ); $candidates = []; /* * Named setup Player Characters are party members too. They do not use the * Entity Party Membership Trait; their presence in runtime.players is the * canonical membership signal for the playthrough. */ if ($selector === 'random_party_character') { $gameConfig = function_exists('wayfarerRuntimeLoadGameConfig') ? wayfarerRuntimeLoadGameConfig( $pdo, $gameId ) : []; $players = isset($runtimeState['players']) && is_array($runtimeState['players']) ? $runtimeState['players'] : []; foreach ($players as $playerKey => $playerState) { if (!is_array($playerState)) { continue; } $name = trim( (string)( $playerState['name'] ?? '' ) ); if ($name === '') { continue; } $support = wayfarerRuntimeStoryletBindingPlayerSupportsTargets( $gameConfig, $playerState, $requiredTargets ); if (empty($support['supported'])) { continue; } $slot = (int)( $playerState['slot'] ?? $playerKey ); $candidates[] = [ 'runtime_scope' => 'player:' . $slot, 'subject_kind' => 'player', 'player_slot' => $slot, 'entity_id' => 0, 'entity_type' => 'player', 'name' => $name, 'target_map' => $support['target_map'], ]; } } foreach ( wayfarerRuntimeLoadActiveEntities( $pdo, $gameId ) as $entity ) { if (!is_array($entity)) { continue; } $entityId = (int)( $entity['id'] ?? 0 ); if ($entityId <= 0) { continue; } $entityType = strtolower( trim( (string)( $entity['entity_type'] ?? '' ) ) ); $entityState = $runtimeState['entities'][ (string)$entityId ] ?? null; if (!is_array($entityState)) { continue; } $traits = isset($entityState['traits']) && is_array($entityState['traits']) ? $entityState['traits'] : []; if ( $selector === 'random_party_character' ) { if ( $entityType !== 'character' || empty( $traits[ 'sys_character_party_member' ] ) ) { continue; } } elseif ( $selector === 'random_possessed_item' ) { $quantity = $traits[ 'sys_item_quantity' ] ?? 0; if ( $entityType !== 'item' || !is_numeric($quantity) || (float)$quantity <= 0 ) { continue; } } else { continue; } $support = wayfarerRuntimeStoryletBindingEntitySupportsTargets( $entity, $requiredTargets ); if (empty($support['supported'])) { continue; } $candidates[] = [ 'runtime_scope' => 'entity:' . $entityId, 'subject_kind' => 'entity', 'player_slot' => null, 'entity_id' => $entityId, 'entity_type' => $entityType, 'name' => trim( (string)( $entity['name'] ?? ('Entity ' . $entityId) ) ), 'target_map' => $support['target_map'], ]; } return $candidates; } function wayfarerRuntimeStoryletBindingsAvailable( PDO $pdo, int $gameId, array $storylet, array $runtimeState ): bool { $definitions = wayfarerRuntimeStoryletBindingDefinitions( $storylet ); foreach ($definitions as $binding) { if ( wayfarerRuntimeStoryletBindingCandidates( $pdo, $gameId, $storylet, $binding, $runtimeState ) === [] ) { return false; } } return true; } function wayfarerRuntimeResolveStoryletBindings( PDO $pdo, int $gameId, array $storylet, array $runtimeState, array $presetBindings = [] ): array { $definitions = wayfarerRuntimeStoryletBindingDefinitions( $storylet ); $resolved = []; foreach ( $definitions as $key => $binding ) { /* WAYFARER_PERSISTENT_STORYLET_BINDINGS_V88 */ if ( isset($presetBindings[$key]) && is_array($presetBindings[$key]) ) { $resolved[$key] = $presetBindings[$key]; continue; } $candidates = wayfarerRuntimeStoryletBindingCandidates( $pdo, $gameId, $storylet, $binding, $runtimeState ); if ($candidates === []) { return [ 'bindings' => [], 'error' => 'Storylet Binding "' . ( $binding['label'] ?? $key ) . '" has no eligible runtime target.', ]; } $selected = $candidates[ random_int( 0, count($candidates) - 1 ) ]; $resolved[$key] = [ 'key' => $key, 'label' => (string)( $binding['label'] ?? $key ), 'selector' => (string)( $binding['selector'] ?? '' ), 'runtime_scope' => (string)( $selected['runtime_scope'] ?? '' ), 'subject_kind' => (string)( $selected['subject_kind'] ?? 'entity' ), 'player_slot' => isset($selected['player_slot']) ? (int)$selected['player_slot'] : null, 'entity_id' => (int)( $selected['entity_id'] ?? 0 ), 'entity_type' => (string)( $selected['entity_type'] ?? '' ), 'name' => (string)$selected['name'], 'target_map' => is_array( $selected['target_map'] ?? null ) ? $selected['target_map'] : [], ]; } /* * Preset bindings that are not explicitly defined on this Storylet are * still carried for narrative interpolation. This is how a Status Outcome * can supply the reserved {{subject}} placeholder without forcing every * presentation-only Storylet to author a duplicate Binding definition. */ foreach ($presetBindings as $key => $binding) { if ( !isset($resolved[$key]) && is_array($binding) ) { $resolved[$key] = $binding; } } return [ 'bindings' => $resolved, 'error' => '', ]; } function wayfarerRuntimeMaterializeStoryletBindingEffect( array $effect, array $bindings ): array { $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); if ( preg_match( '/^binding:([a-z][a-z0-9_]{0,31})$/', $scope, $match ) !== 1 ) { return [ 'effect' => $effect, 'error' => '', ]; } $key = $match[1]; $binding = $bindings[$key] ?? null; if (!is_array($binding)) { return [ 'effect' => [], 'error' => 'Resolved Storylet Binding "' . $key . '" is missing.', ]; } $runtimeScope = strtolower( trim( (string)( $binding['runtime_scope'] ?? '' ) ) ); /* * Compatibility for V79-V84 saved runtime binding records, which stored * only entity_id. */ if ($runtimeScope === '') { $entityId = (int)( $binding['entity_id'] ?? 0 ); if ($entityId > 0) { $runtimeScope = 'entity:' . $entityId; } } if ( preg_match( '/^(?:entity:[1-9][0-9]*|player:[0-9]+)$/', $runtimeScope ) !== 1 ) { return [ 'effect' => [], 'error' => 'Resolved Storylet Binding "' . $key . '" does not point to a valid Party Member or Entity.', ]; } $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); $targetType = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); /* WAYFARER_STATUS_BINDING_RUNTIME_V87 */ if ( $targetType === 'status' && preg_match('/^[1-9][0-9]*$/', $targetId) === 1 ) { $materialized = $effect; $materialized['target_scope'] = $runtimeScope; $materialized['target_id'] = $targetId; return [ 'effect' => $materialized, 'error' => '', ]; } $targetMap = is_array( $binding['target_map'] ?? null ) ? $binding['target_map'] : []; $entityScope = strpos( $runtimeScope, 'entity:' ) === 0; $actualTargetId = $targetMap[$targetId] ?? ( $entityScope && ( $targetId === 'sys_item_quantity' || $targetId === 'sys_character_party_member' ) ? $targetId : '' ); if ($actualTargetId === '') { return [ 'effect' => [], 'error' => 'The selected subject for Binding "' . $key . '" does not expose the required Trait.', ]; } $materialized = $effect; $materialized['target_scope'] = $runtimeScope; $materialized['target_id'] = $actualTargetId; if ( $entityScope && $actualTargetId === 'sys_item_quantity' ) { $materialized['target_system_key'] = 'quantity'; } elseif ( $entityScope && $actualTargetId === 'sys_character_party_member' ) { $materialized['target_system_key'] = 'party_member'; } else { unset( $materialized['target_system_key'] ); } return [ 'effect' => $materialized, 'error' => '', ]; } function wayfarerRuntimeInterpolateStoryletBindings( string $text, array $bindings ): string { if ( $text === '' || $bindings === [] ) { return $text; } $replace = function ( array $match ) use ( $bindings ): string { $key = wayfarerRuntimeStoryletBindingNormalizeKey( (string)( $match[1] ?? '' ) ); $binding = $bindings[$key] ?? null; if (!is_array($binding)) { return $match[0]; } return (string)( $binding['name'] ?? $match[0] ); }; /* * {{victim.name}} and the shorter {{victim}} both resolve to the bound * subject's current name. */ $text = preg_replace_callback( '/\{\{\s*([a-z][a-z0-9_]*)\.name\s*\}\}/i', $replace, $text ) ?? $text; $text = preg_replace_callback( '/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/i', $replace, $text ) ?? $text; return $text; } ================================================================================ FILE: _wayfarer_backups/v92-20260930-134321/play/includes/storylets.php ================================================================================ root Manual Storylet -> Storylet presentation | -> eligible child Branch -> child presentation -> ... | | Automatic/Chance firing is intentionally not handled here yet. | */ function wayfarerRuntimeLoadStorylet( PDO $pdo, int $gameId, int $storyletId ): ?array { if ($gameId <= 0 || $storyletId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT id, game_id, name, description, parent_storylet_id, branch_label, branch_sort_order, trigger_type, max_occurrences, status, config FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $storyletId, $gameId, ]); $storylet = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$storylet) { return null; } $storylet['_runtime_config'] = wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); return $storylet; } function wayfarerRuntimeStoryletOccurrenceCount( array $runtimeState, int $storyletId ): int { if ($storyletId <= 0) { return 0; } return max( 0, (int)( $runtimeState['storylets'][(string)$storyletId]['occurrences'] ?? 0 ) ); } function wayfarerRuntimeStoryletOccurrenceLimitReached( array $storylet, array $runtimeState ): bool { $maximum = $storylet['max_occurrences'] ?? null; if ( $maximum === null || $maximum === '' || !is_numeric($maximum) ) { return false; } $maximum = (int)$maximum; if ($maximum <= 0) { return false; } return wayfarerRuntimeStoryletOccurrenceCount( $runtimeState, (int)($storylet['id'] ?? 0) ) >= $maximum; } function wayfarerRuntimeStoryletConditionsPass( array $storylet, array &$runtimeState ): bool { $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); $conditions = $config['conditions'] ?? null; $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; return wayfarerConditionsPass( $conditions, $resolver ); } function wayfarerRuntimeStoryletIsEligible( array $storylet, array &$runtimeState ): bool { if ( strtolower( trim( (string)( $storylet['status'] ?? '' ) ) ) !== 'active' ) { return false; } if ( wayfarerRuntimeStoryletOccurrenceLimitReached( $storylet, $runtimeState ) ) { return false; } return wayfarerRuntimeStoryletConditionsPass( $storylet, $runtimeState ); } function wayfarerRuntimeStoryletPlayerPayload( array $storylet, array $runtimeState, bool $asBranch = false ): array { $name = trim( (string)( $storylet['name'] ?? '' ) ); $branchLabel = trim( (string)( $storylet['branch_label'] ?? '' ) ); $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); return [ 'id' => (int)( $storylet['id'] ?? 0 ), 'name' => $name, 'label' => $asBranch && $branchLabel !== '' ? $branchLabel : $name, 'description' => trim( (string)( $storylet['description'] ?? '' ) ), 'priority' => (int)( $config['priority'] ?? 100 ), 'occurrences' => wayfarerRuntimeStoryletOccurrenceCount( $runtimeState, (int)( $storylet['id'] ?? 0 ) ), 'max_occurrences' => $storylet['max_occurrences'] === null || $storylet['max_occurrences'] === '' ? null : (int)$storylet['max_occurrences'], ]; } function wayfarerRuntimeEligibleRootManualStorylets( PDO $pdo, int $gameId, array &$runtimeState ): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare( " SELECT id, game_id, name, description, parent_storylet_id, branch_label, branch_sort_order, trigger_type, max_occurrences, status, config FROM wf_storylets WHERE game_id = ? AND status = 'active' AND trigger_type = 'manual' AND ( parent_storylet_id IS NULL OR parent_storylet_id = 0 ) ORDER BY id " ); $stmt->execute([ $gameId ]); $actions = []; foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $storylet ) { $storylet['_runtime_config'] = wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $runtimeState ) || !wayfarerRuntimeStoryletBindingsAvailable( $pdo, $gameId, $storylet, $runtimeState ) ) { continue; } $actions[] = wayfarerRuntimeStoryletPlayerPayload( $storylet, $runtimeState, false ); } usort( $actions, function ( array $a, array $b ): int { $priority = ((int)($a['priority'] ?? 100)) <=> ((int)($b['priority'] ?? 100)); if ($priority !== 0) { return $priority; } $label = strcasecmp( (string)($a['label'] ?? ''), (string)($b['label'] ?? '') ); if ($label !== 0) { return $label; } return ((int)($a['id'] ?? 0)) <=> ((int)($b['id'] ?? 0)); } ); return $actions; } function wayfarerRuntimeEligibleStoryletBranches( PDO $pdo, int $gameId, int $parentStoryletId, array &$runtimeState ): array { if ( $gameId <= 0 || $parentStoryletId <= 0 ) { return []; } $stmt = $pdo->prepare( " SELECT id, game_id, name, description, parent_storylet_id, branch_label, branch_sort_order, trigger_type, max_occurrences, status, config FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? AND status = 'active' ORDER BY branch_sort_order, name, id " ); $stmt->execute([ $gameId, $parentStoryletId, ]); $branches = []; foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $storylet ) { $storylet['_runtime_config'] = wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $runtimeState ) || !wayfarerRuntimeStoryletBindingsAvailable( $pdo, $gameId, $storylet, $runtimeState ) ) { continue; } $payload = wayfarerRuntimeStoryletPlayerPayload( $storylet, $runtimeState, true ); $payload['branch_sort_order'] = (int)( $storylet['branch_sort_order'] ?? 0 ); $branches[] = $payload; } return $branches; } function wayfarerRuntimeEnsureStoryletTargetState( PDO $pdo, int $gameId, array &$runtimeState ): void { wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $runtimeState ); wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $runtimeState ); wayfarerRuntimeEnsureEntityState( $pdo, $gameId, $runtimeState ); wayfarerRuntimeEnsurePlayerAndGameState( $pdo, $gameId, $runtimeState ); } function wayfarerRuntimeStoryletTraitDefinitions( PDO $pdo, int $gameId, array $runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionConfig = []; if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $raw = $stmt->fetchColumn(); if ($raw !== false) { $regionConfig = wayfarerRuntimeDecodeTraitConfig( $raw ); } } return wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); } /* |-------------------------------------------------------------------------- | Storylet-to-root navigation runtime | WAYFARER_STORYLET_ROOT_CONNECTIONS_V82 |-------------------------------------------------------------------------- */ function wayfarerRuntimeConsumeStoryletTransition( array &$runtimeState ): int { $storyletId = (int)( $runtimeState['runtime']['pending_storylet_transition'] ?? 0 ); if ( isset($runtimeState['runtime']) && is_array($runtimeState['runtime']) ) { unset( $runtimeState['runtime']['pending_storylet_transition'] ); } return max( 0, $storyletId ); } function wayfarerRuntimeEnterLinkedRootStorylet( PDO $pdo, int $gameId, int $storyletId, array &$runtimeState, array $visitedStorylets = [], array $inheritedBindings = [] ): array { $chain = []; $nextStoryletId = $storyletId; while ($nextStoryletId > 0) { if (count($chain) >= 16) { throw new RuntimeException( 'Storylet Continue To chain is too deep.' ); } if ( isset( $visitedStorylets[ (string)$nextStoryletId ] ) ) { throw new RuntimeException( 'Storylet Continue To would create a circular Storylet chain.' ); } $visitedStorylets[ (string)$nextStoryletId ] = true; $linkedStorylet = wayfarerRuntimeLoadStorylet( $pdo, $gameId, $nextStoryletId ); if (!$linkedStorylet) { throw new RuntimeException( 'The linked root Storylet could not be found.' ); } if ( !empty( $linkedStorylet[ 'parent_storylet_id' ] ) ) { throw new RuntimeException( 'Storylet Continue To may target only a root Storylet.' ); } wayfarerRuntimeEnsureStoryletTargetState( $pdo, $gameId, $runtimeState ); if ( !wayfarerRuntimeStoryletIsEligible( $linkedStorylet, $runtimeState ) ) { throw new RuntimeException( 'The linked root Storylet is not currently eligible.' ); } $linkedConfig = $linkedStorylet[ '_runtime_config' ] ?? []; $tickCost = max( 0, (int)( $linkedConfig[ 'tick_cost' ] ?? 0 ) ); if ($tickCost > 0) { throw new RuntimeException( 'Storylet Tick Cost runtime is not implemented yet. Set Tick Cost to 0 for linked Storylets.' ); } /* WAYFARER_PERSISTENT_STORYLET_SUBJECT_V88 */ $linkedPresetBindings = []; if ( isset($inheritedBindings['subject']) && is_array($inheritedBindings['subject']) ) { $subjectScope = (string)( $inheritedBindings['subject']['runtime_scope'] ?? '' ); $subjectRefresh = wayfarerRuntimeStoryletPresetCharacterBinding( $pdo, $gameId, $linkedStorylet, $runtimeState, $subjectScope, 'subject', (string)( $inheritedBindings['subject']['label'] ?? 'Subject' ) ); if (($subjectRefresh['error'] ?? '') !== '') { throw new RuntimeException( (string)$subjectRefresh['error'] ); } if (is_array($subjectRefresh['binding'] ?? null)) { $linkedPresetBindings['subject'] = $subjectRefresh['binding']; } } $bindingResult = wayfarerRuntimeResolveStoryletBindings( $pdo, $gameId, $linkedStorylet, $runtimeState, $linkedPresetBindings ); if ( ($bindingResult['error'] ?? '') !== '' ) { throw new RuntimeException( (string)$bindingResult['error'] ); } $resolvedBindings = is_array( $bindingResult['bindings'] ?? null ) ? $bindingResult['bindings'] : []; /* WAYFARER_PERSISTENT_STORYLET_SUBJECT_V88 */ $inheritedBindings = isset($resolvedBindings['subject']) && is_array($resolvedBindings['subject']) ? [ 'subject' => $resolvedBindings['subject'], ] : []; $effects = wayfarerRuntimeApplyStoryletEffects( $pdo, $gameId, $linkedStorylet, $runtimeState, $resolvedBindings ); $occurrence = wayfarerRuntimeIncrementStoryletOccurrence( $runtimeState, $nextStoryletId ); $chain[] = [ 'storylet_id' => $nextStoryletId, 'bindings' => $resolvedBindings, 'effects' => $effects, 'occurrence' => $occurrence, ]; $nextTransitionId = wayfarerRuntimeConsumeStoryletTransition( $runtimeState ); if ($nextTransitionId <= 0) { return [ 'storylet' => $linkedStorylet, 'bindings' => $resolvedBindings, 'effects' => $effects, 'occurrence' => $occurrence, 'chain' => $chain, ]; } $nextStoryletId = $nextTransitionId; } throw new RuntimeException( 'Storylet Continue To did not resolve to a valid Storylet.' ); } /* * Enter a root Storylet from an internal gameplay event while already inside * the caller's locked runtime transaction. Status Outcomes use this path so * they can supply the affected character as the persistent "subject" binding. * WAYFARER_TRIGGERED_STORYLET_RUNTIME_V88 */ function wayfarerRuntimeEnterTriggeredStoryletInState( PDO $pdo, int $gameId, int $storyletId, array &$runtimeState, array $presetBindings = [], string $trigger = 'event' ): array { if ( isset($runtimeState['runtime']['presentation']) && is_array($runtimeState['runtime']['presentation']) ) { throw new RuntimeException( 'A triggered Storylet cannot open while another presentation is active.' ); } $storylet = wayfarerRuntimeLoadStorylet( $pdo, $gameId, $storyletId ); if (!$storylet) { throw new RuntimeException( 'Triggered Storylet could not be found.' ); } if (!empty($storylet['parent_storylet_id'])) { throw new RuntimeException( 'Triggered Storylets must be root Storylets.' ); } if ( strtolower(trim((string)($storylet['status'] ?? 'draft'))) !== 'active' ) { throw new RuntimeException( 'Triggered Storylet is not Active.' ); } wayfarerRuntimeEnsureStoryletTargetState( $pdo, $gameId, $runtimeState ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $runtimeState ) ) { throw new RuntimeException( 'Triggered Storylet is not currently eligible.' ); } $config = $storylet['_runtime_config'] ?? []; if ( max(0, (int)($config['tick_cost'] ?? 0)) > 0 ) { throw new RuntimeException( 'Triggered Storylet Tick Cost runtime is not implemented yet. Set Tick Cost to 0.' ); } $bindingResult = wayfarerRuntimeResolveStoryletBindings( $pdo, $gameId, $storylet, $runtimeState, $presetBindings ); if (($bindingResult['error'] ?? '') !== '') { throw new RuntimeException( (string)$bindingResult['error'] ); } $resolvedBindings = is_array($bindingResult['bindings'] ?? null) ? $bindingResult['bindings'] : []; $effects = wayfarerRuntimeApplyStoryletEffects( $pdo, $gameId, $storylet, $runtimeState, $resolvedBindings ); $occurrence = wayfarerRuntimeIncrementStoryletOccurrence( $runtimeState, $storyletId ); $presentationStorylet = $storylet; $presentationBindings = $resolvedBindings; $presentationOccurrence = $occurrence; $presentationTrigger = strtolower(trim($trigger)); $transitionId = wayfarerRuntimeConsumeStoryletTransition( $runtimeState ); if ($transitionId > 0) { $linkedResult = wayfarerRuntimeEnterLinkedRootStorylet( $pdo, $gameId, $transitionId, $runtimeState, [ (string)$storyletId => true, ], isset($resolvedBindings['subject']) && is_array($resolvedBindings['subject']) ? [ 'subject' => $resolvedBindings['subject'], ] : [] ); $presentationStorylet = $linkedResult['storylet']; $presentationBindings = $linkedResult['bindings']; $presentationOccurrence = $linkedResult['occurrence']; $presentationTrigger = 'transition'; } $presentationStoryletId = (int)($presentationStorylet['id'] ?? 0); $runtimeState['runtime']['presentation'] = [ 'type' => 'storylet', 'id' => $presentationStoryletId, 'phase' => 'presentation', 'trigger' => $presentationTrigger, 'tick' => (int)($runtimeState['runtime']['tick'] ?? 0), 'bindings' => $presentationBindings, ]; $runtimeState['runtime']['last_storylet_entry'] = [ 'storylet_id' => $presentationStoryletId, 'parent_storylet_id' => (int)($presentationStorylet['parent_storylet_id'] ?? 0), 'entry_mode' => $presentationTrigger, 'occurrence' => $presentationOccurrence, 'bindings' => $presentationBindings, 'tick' => (int)($runtimeState['runtime']['tick'] ?? 0), ]; return [ 'storylet' => $presentationStorylet, 'bindings' => $presentationBindings, 'effects' => $effects, 'occurrence' => $presentationOccurrence, 'presentation' => $runtimeState['runtime']['presentation'], ]; } function wayfarerRuntimeApplyStoryletEffects( PDO $pdo, int $gameId, array $storylet, array &$runtimeState, array $bindings = [] ): array { $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); $effects = $config['effects'] ?? []; if (!is_array($effects)) { $effects = []; } $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $traitDefinitions = wayfarerRuntimeStoryletTraitDefinitions( $pdo, $gameId, $runtimeState ); wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $regionId ); $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { throw new RuntimeException( 'Malformed Storylet Effect.' ); } $materialized = wayfarerRuntimeMaterializeStoryletBindingEffect( $effect, $bindings ); if ( ($materialized['error'] ?? '') !== '' ) { throw new RuntimeException( 'Storylet Binding Effect failed: ' . (string)$materialized['error'] ); } $runtimeEffect = $materialized['effect'] ?? $effect; if ( (string)( $runtimeEffect['target_scope'] ?? '' ) === 'storylet' && (string)( $runtimeEffect['target_type'] ?? '' ) === 'transition' ) { $selection = wayfarerRuntimeSelectEffectBranch( $runtimeState, $runtimeEffect, $regionId ); if ( ($selection['error'] ?? '') !== '' ) { throw new RuntimeException( 'Storylet Continue To Effect failed: ' . (string)$selection['error'] ); } $matched = !empty( $selection['matched'] ); $branch = is_array( $selection['branch'] ?? null ) ? $selection['branch'] : []; $operation = strtolower( trim( (string)( $branch['operation'] ?? 'none' ) ) ); $targetStoryletId = (int)( $runtimeEffect['target_id'] ?? 0 ); $applied = $matched && $operation === 'continue'; if ($applied) { if ($targetStoryletId <= 0) { throw new RuntimeException( 'Storylet Continue To Effect has an invalid destination.' ); } $existingTransition = (int)( $runtimeState['runtime']['pending_storylet_transition'] ?? 0 ); if ( $existingTransition > 0 && $existingTransition !== $targetStoryletId ) { throw new RuntimeException( 'More than one Storylet Continue To Effect matched.' ); } $runtimeState['runtime']['pending_storylet_transition'] = $targetStoryletId; } $reports[] = [ 'scope' => 'storylet', 'type' => 'transition', 'target_id' => (string)$targetStoryletId, 'operation' => $operation, 'applied' => $applied, 'matched' => $matched, 'branch_index' => $selection['branch_index'] ?? null, 'error' => '', ]; continue; } $report = wayfarerApplyRuntimeOneTimeEffect( $runtimeState, $runtimeEffect, $regionId, $traitDefinitions ); if (!empty($report['error'])) { throw new RuntimeException( 'Storylet Effect failed: ' . (string)$report['error'] ); } $reports[] = $report; } /* WAYFARER_STORYLET_EFFECT_FEEDBACK_V70 */ if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_storylet_effects'] = [ 'storylet_id' => (int)( $storylet['id'] ?? 0 ), 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), 'reports' => $reports, 'changes' => wayfarerRuntimeBuildEffectChangeEvents( $pdo, $gameId, $runtimeState, $reports ), ]; return $reports; } function wayfarerRuntimeIncrementStoryletOccurrence( array &$runtimeState, int $storyletId ): int { if ( !isset($runtimeState['storylets']) || !is_array($runtimeState['storylets']) ) { $runtimeState['storylets'] = []; } $key = (string)$storyletId; if ( !isset($runtimeState['storylets'][$key]) || !is_array($runtimeState['storylets'][$key]) ) { $runtimeState['storylets'][$key] = []; } $count = wayfarerRuntimeStoryletOccurrenceCount( $runtimeState, $storyletId ) + 1; $runtimeState['storylets'][$key]['occurrences'] = $count; $runtimeState['storylets'][$key]['last_entered_tick'] = (int)( $runtimeState['runtime']['tick'] ?? 0 ); return $count; } function wayfarerRuntimeEnterStorylet( PDO $pdo, int $gameUserId, int $storyletId, string $entryMode = 'action', int $expectedParentStoryletId = 0 ): array { $entryMode = strtolower( trim($entryMode) ); if ( $entryMode !== 'action' && $entryMode !== 'branch' ) { throw new RuntimeException( 'Invalid Storylet entry mode.' ); } try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $gameId = (int)$row['game_id']; $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } if ( isset($state['runtime']['ending']) && is_array($state['runtime']['ending']) ) { throw new RuntimeException( 'This game has ended.' ); } $activePresentation = $state['runtime']['presentation'] ?? null; $storylet = wayfarerRuntimeLoadStorylet( $pdo, $gameId, $storyletId ); if (!$storylet) { throw new RuntimeException( 'Storylet not found.' ); } if ($entryMode === 'action') { if (is_array($activePresentation)) { throw new RuntimeException( 'Continue the current presentation before choosing an Action.' ); } if ( !empty($storylet['parent_storylet_id']) || strtolower( trim( (string)( $storylet['trigger_type'] ?? '' ) ) ) !== 'manual' ) { throw new RuntimeException( 'That Storylet is not an available root Manual Action.' ); } } else { if ( !is_array($activePresentation) || (string)( $activePresentation['type'] ?? '' ) !== 'storylet' || (int)( $activePresentation['id'] ?? 0 ) <= 0 ) { throw new RuntimeException( 'There is no active Storylet Branch to choose from.' ); } $parentStoryletId = (int)( $activePresentation['id'] ?? 0 ); if ( $expectedParentStoryletId > 0 && $parentStoryletId !== $expectedParentStoryletId ) { throw new RuntimeException( 'The active Storylet changed before the Branch was selected.' ); } if ( (int)( $storylet['parent_storylet_id'] ?? 0 ) !== $parentStoryletId ) { throw new RuntimeException( 'That Storylet is not a Branch of the active Storylet.' ); } } wayfarerRuntimeEnsureStoryletTargetState( $pdo, $gameId, $state ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $state ) || ( $entryMode === 'action' && !wayfarerRuntimeStoryletBindingsAvailable( $pdo, $gameId, $storylet, $state ) ) ) { throw new RuntimeException( 'That Storylet is no longer eligible.' ); } $config = $storylet['_runtime_config'] ?? []; $tickCost = max( 0, (int)( $config['tick_cost'] ?? 0 ) ); /* * V59 proves interactive Storylets before refactoring the normal tick * pipeline into a reusable "consume N ticks" helper. Reject non-zero * cost rather than silently ignoring authored time passage. */ if ($tickCost > 0) { throw new RuntimeException( 'Storylet Tick Cost runtime is not implemented yet. Set Tick Cost to 0 for V59.' ); } /* WAYFARER_STORYLET_BINDING_RUNTIME_V79 */ /* WAYFARER_PERSISTENT_STORYLET_SUBJECT_V88 */ $presetBindings = []; if ( $entryMode === 'branch' && isset($activePresentation['bindings']['subject']) && is_array($activePresentation['bindings']['subject']) ) { $subjectScope = (string)( $activePresentation['bindings']['subject']['runtime_scope'] ?? '' ); $subjectRefresh = wayfarerRuntimeStoryletPresetCharacterBinding( $pdo, $gameId, $storylet, $state, $subjectScope, 'subject', (string)( $activePresentation['bindings']['subject']['label'] ?? 'Subject' ) ); if (($subjectRefresh['error'] ?? '') !== '') { throw new RuntimeException( (string)$subjectRefresh['error'] ); } if (is_array($subjectRefresh['binding'] ?? null)) { $presetBindings['subject'] = $subjectRefresh['binding']; } } $bindingResult = wayfarerRuntimeResolveStoryletBindings( $pdo, $gameId, $storylet, $state, $presetBindings ); if ( ($bindingResult['error'] ?? '') !== '' ) { throw new RuntimeException( (string)$bindingResult['error'] ); } $resolvedBindings = is_array( $bindingResult['bindings'] ?? null ) ? $bindingResult['bindings'] : []; $effects = wayfarerRuntimeApplyStoryletEffects( $pdo, $gameId, $storylet, $state, $resolvedBindings ); $occurrence = wayfarerRuntimeIncrementStoryletOccurrence( $state, $storyletId ); if ($entryMode === 'branch') { $state['runtime']['last_presentation'] = $activePresentation; } $presentationStorylet = $storylet; $presentationBindings = $resolvedBindings; $presentationOccurrence = $occurrence; $presentationTrigger = $entryMode; $transitionId = wayfarerRuntimeConsumeStoryletTransition( $state ); if ($transitionId > 0) { $linkedResult = wayfarerRuntimeEnterLinkedRootStorylet( $pdo, $gameId, $transitionId, $state, [ (string)$storyletId => true, ], isset($resolvedBindings['subject']) && is_array($resolvedBindings['subject']) ? [ 'subject' => $resolvedBindings['subject'], ] : [] ); $presentationStorylet = $linkedResult['storylet']; $presentationBindings = $linkedResult['bindings']; $presentationOccurrence = $linkedResult['occurrence']; $presentationTrigger = 'transition'; $state['runtime']['last_storylet_transition'] = [ 'from_storylet_id' => $storyletId, 'to_storylet_id' => (int)( $presentationStorylet['id'] ?? 0 ), 'chain' => $linkedResult['chain'], 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } else { unset( $state['runtime']['last_storylet_transition'] ); } $presentationStoryletId = (int)( $presentationStorylet['id'] ?? 0 ); $state['runtime']['presentation'] = [ 'type' => 'storylet', 'id' => $presentationStoryletId, 'phase' => 'presentation', 'trigger' => $presentationTrigger, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), 'bindings' => $presentationBindings, ]; $state['runtime']['last_storylet_entry'] = [ 'storylet_id' => $presentationStoryletId, 'parent_storylet_id' => (int)( $presentationStorylet['parent_storylet_id'] ?? 0 ), 'entry_mode' => $presentationTrigger, 'occurrence' => $presentationOccurrence, 'bindings' => $presentationBindings, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; $ending = wayfarerRuntimeEvaluateGameEndings( $pdo, $gameId, $state ); $encoded = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encoded, $gameUserId, ]); $pdo->commit(); $presentation = $state['runtime']['presentation'] ?? null; return [ 'game_user_id' => (int)$row['id'], 'game_id' => $gameId, 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, $gameId, $state ), 'presentation' => is_array($presentation) ? $presentation : null, 'ending' => $ending, 'effects' => $effects, 'occurrence' => $presentationOccurrence, 'actions' => $ending === null && !is_array($presentation) ? wayfarerRuntimeEligibleRootManualStorylets( $pdo, $gameId, $state ) : [], ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: _wayfarer_backups/v93-20260930-145229/assets/js/traits.js ================================================================================ /* * Wayfarer reusable Trait editor * WAYFARER_MULTI_TRAIT_EDITOR_V52 */ (function () { 'use strict'; function parseSurfaces(editor) { try { const parsed = JSON.parse( editor.dataset.traitSurfaces || '[]' ); return Array.isArray(parsed) ? parsed.filter(function (surface) { return surface === 'inventory' || surface === 'status'; }) : []; } catch (error) { return []; } } function surfaceLabel(surface) { return surface === 'inventory' ? 'Inventory' : 'Status'; } function escapeHtml(value) { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function updateTraitFields(traitRow) { if (!traitRow) { return; } const typeSelect = traitRow.querySelector('.trait-type'); const numberInitial = traitRow.querySelector('.trait-number-initial'); const booleanInitial = traitRow.querySelector('.trait-boolean-initial'); if (!typeSelect || !numberInitial || !booleanInitial) { return; } const isBoolean = typeSelect.value === 'boolean'; traitRow.querySelectorAll('.trait-number-fields') .forEach(function (field) { field.style.display = isBoolean ? 'none' : ''; }); traitRow.querySelectorAll('.trait-not-applicable') .forEach(function (field) { field.style.display = isBoolean ? '' : 'none'; }); numberInitial.style.display = isBoolean ? 'none' : ''; booleanInitial.style.display = isBoolean ? '' : 'none'; } function nextIndex(editor) { let max = -1; editor.querySelectorAll('.trait-row[data-trait-index]') .forEach(function (row) { const value = Number(row.dataset.traitIndex); if (Number.isInteger(value) && value > max) { max = value; } }); return max + 1; } function createTrait(editor) { const container = editor.querySelector('[data-traits-container]'); if (!container) { return; } const fieldName = editor.dataset.traitFieldName || 'traits'; const index = nextIndex(editor); const safeFieldName = escapeHtml(fieldName); const surfaces = parseSurfaces(editor); const row = document.createElement('tr'); row.className = 'trait-row'; row.dataset.traitIndex = String(index); row.innerHTML = ` — — `; const notesRow = document.createElement('tr'); notesRow.className = 'trait-notes-row'; notesRow.dataset.traitIndex = String(index); const surfaceControls = surfaces.map(function (surface) { return ` `; }).join(''); notesRow.innerHTML = ` ${surfaceControls !== '' ? `
Show to Player: ${surfaceControls} Controls where this trait is displayed to the player. Unchecked traits remain available to gameplay logic.
` : ''} `; container.appendChild(row); container.appendChild(notesRow); updateTraitFields(row); const nameInput = row.querySelector('input[type="text"]'); if (nameInput) { nameInput.focus(); } } function initializeEditor(editor) { if (!editor || editor.dataset.traitEditorReady === '1') { return; } editor.dataset.traitEditorReady = '1'; editor.querySelectorAll('.trait-row') .forEach(updateTraitFields); editor.addEventListener('change', function (event) { if (event.target.classList.contains('trait-type')) { updateTraitFields(event.target.closest('.trait-row')); } }); editor.addEventListener('click', function (event) { const addButton = event.target.closest('[data-add-trait]'); if (addButton) { createTrait(editor); return; } const removeButton = event.target.closest('.remove-trait'); if (!removeButton) { return; } const traitRow = removeButton.closest('.trait-row'); if (!traitRow) { return; } const index = traitRow.dataset.traitIndex; const notesRow = editor.querySelector( '.trait-notes-row[data-trait-index="' + index + '"]' ); if (notesRow) { notesRow.remove(); } traitRow.remove(); }); } function initializeAll() { document.querySelectorAll('[data-wayfarer-trait-editor]') .forEach(initializeEditor); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeAll); } else { initializeAll(); } })(); ================================================================================ FILE: _wayfarer_backups/v93-20260930-145229/config/builder-types.php ================================================================================ [ 'label' => 'Regions', 'singular' => 'Region', 'table' => 'wf_regions', 'list_field' => 'name', 'traits' => true, /* * Permanent deletion is available from the edit screen. * A Region cannot be deleted while Locations still belong to it. */ 'delete' => [ 'enabled' => true, 'dependencies' => [ [ 'table' => 'wf_locations', 'foreign_key' => 'region_id', 'label' => 'Locations' ] ] ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'navigation_type' => [ 'label' => 'Navigation Type', 'type' => 'select', 'required' => true, 'options' => [ 'linear' => 'Linear', 'grid' => 'Grid', 'freeform' => 'Freeform' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], /* WAYFARER_LINEAR_DISTANCE_LABEL_V61 */ 'config_fields' => [ 'distance_label' => [ 'label' => 'Distance Label', 'type' => 'text', 'default' => 'units', 'help' => 'Player-facing unit used for Linear travel distance, such as miles, km, leagues, or units.' ] ] ], 'locations' => [ 'label' => 'Locations', 'singular' => 'Location', 'table' => 'wf_locations', 'list_field' => 'name', 'traits' => true, 'list_columns' => [ 'name' => [ 'label' => 'Name', 'source' => 'field', 'field' => 'name', 'sortable' => true ], 'region' => [ 'label' => 'Region', 'source' => 'relationship', 'relationship' => 'region_id', 'sortable' => true ], 'position' => [ 'label' => 'Position', 'source' => 'config', 'field' => 'position', 'type' => 'number', 'sortable' => true ] ], /* * Permanent deletion is available from the edit screen. */ 'delete' => [ 'enabled' => true ], 'scope' => [ 'type' => 'region', 'field' => 'region_id', 'scope_table' => 'wf_regions', 'scope_key' => 'id', 'game_field' => 'game_id' ], 'relationships' => [ 'region_id' => [ 'label' => 'Region', 'type' => 'select_record', 'table' => 'wf_regions', 'value_field' => 'id', 'label_field' => 'name', 'game_field' => 'game_id', 'required' => true ] ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], 'config_fields' => [ 'position' => [ 'label' => 'Position', 'type' => 'number', 'min' => 0, 'required' => true, 'condition' => [ 'relationship' => 'region_id', 'source_table' => 'wf_regions', 'source_key' => 'id', 'source_field' => 'navigation_type', 'equals' => 'linear' ] ] ] ], 'entities' => [ 'label' => 'Entities', 'singular' => 'Entity', 'table' => 'wf_entities', 'list_field' => 'name', 'traits' => true, /* * Permanent deletion is available from the edit screen. * Additional dependency rules can be added here later as * Storylets and other authored objects begin referencing Entities. */ 'delete' => [ 'enabled' => true ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'entity_type' => [ 'label' => 'Entity Type', 'type' => 'select', 'required' => true, 'default' => 'item', 'options' => [ 'item' => 'Item', 'character' => 'Character' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], /* * System traits use the same stored config.traits collection as * author-created traits. They are locked in the authoring UI. */ 'system_traits' => [ 'selector_field' => 'entity_type', 'after_field' => 'entity_type', 'groups' => [ 'item' => [ 'label' => 'Item Settings', 'help' => 'These are required Item properties used by Wayfarer. ' . 'They cannot be removed or renamed.', 'traits' => [ 'quantity' => [ /* * WAYFARER_ITEM_QUANTITY_CONTEXT_V44 */ 'label' => 'Starting Quantity', 'type' => 'number', 'default' => 0, 'min' => 0, 'required' => true, /* * The minimum is a Wayfarer system rule, not an * author-editable gameplay setting. */ 'show_system_min' => true, 'min_label' => 'Minimum Quantity', 'min_help' => 'Wayfarer Item quantities cannot fall below 0.', 'allow_author_max' => true, 'max_label' => 'Maximum Quantity', 'max_help' => 'The maximum quantity the player can hold during gameplay. ' . 'Leave blank for no maximum.', 'help' => 'Sets the quantity of this Item when gameplay is initialized. ' . 'Effects may increase or decrease the quantity during play. ' . 'A Starting Quantity of 0 means the player begins without this Item.' ], 'inventory_visibility' => [ 'label' => 'Inventory Visibility', 'type' => 'select', 'default' => 'while_possessed', 'required' => true, 'options' => [ 'while_possessed' => 'Only while possessed', 'once_discovered' => 'Once discovered', 'always' => 'Always', 'never' => 'Never' ], 'help' => 'Controls when this item appears in the player inventory. ' . '"Only while possessed" hides the item whenever its quantity ' . 'is 0. "Once discovered" hides it until the player first ' . 'obtains or discovers it, then keeps it visible even if its ' . 'quantity later returns to 0. "Always" shows the item from ' . 'the beginning of the game. "Never" keeps the item hidden ' . 'from the player inventory while still allowing gameplay ' . 'conditions and effects to use it.' ] ] ], 'character' => [ 'label' => 'Character Settings', 'help' => 'These are required Character properties used by Wayfarer. ' . 'They cannot be removed or renamed.', 'traits' => [ 'party_member' => [ 'label' => 'Starts in Party', 'type' => 'binary', 'default' => 0, 'required' => true, 'help' => 'Determines whether this character begins the game ' . 'as a member of the player party. No stores 0; ' . 'Yes stores 1. Storylets can later change this ' . 'state to add or remove the character from the party.' ] ] ] ] ] ], /* WAYFARER_STATUSES_V87 */ 'statuses' => [ 'label' => 'Statuses', 'singular' => 'Status', 'table' => 'wf_statuses', 'list_field' => 'name', 'list_columns' => [ 'name' => [ 'label' => 'Name', 'source' => 'field', 'field' => 'name', 'sortable' => true ], 'status' => [ 'label' => 'Status', 'source' => 'field', 'field' => 'status', 'sortable' => true ] ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true, 'help' => 'Author-facing and player-facing name of this persistent Status.' ], 'description' => [ 'label' => 'Description', 'type' => 'textarea', 'help' => 'Optional player-facing description shown while this Status is active.' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ], 'help' => 'Only Active Status definitions run during gameplay. Activate a Status before testing Storylet Effects that set it.' ] ], 'config_fields' => [ /* WAYFARER_STATUS_OWNERS_V92 */ 'applies_to' => [ 'label' => 'Applies To', 'type' => 'select', 'default' => 'character', 'required' => true, 'options' => [ 'character' => 'Characters', 'item' => 'Items', 'both' => 'Characters & Items' ], 'help' => 'Controls which runtime objects may own this Status. Characters includes named Player Characters and Character Entities. Item Statuses apply to the Item Entity/stack as a whole.' ], 'show_to_player' => [ 'label' => 'Show to Player', 'type' => 'select', 'default' => '1', 'required' => true, 'options' => [ '1' => 'Yes', '0' => 'No' ], 'help' => 'If Yes, the Status appears beneath the affected party member on the player Status screen.' ], /* WAYFARER_STATUS_LIFECYCLE_DISPLAY_V88 */ 'show_stage_to_player' => [ 'label' => 'Show Current Stage to Player', 'type' => 'select', 'default' => '1', 'required' => true, 'options' => [ '1' => 'Yes', '0' => 'No' ], 'help' => 'If Yes, a timed Status may show its current Stage beneath the Status name.' ], 'show_time_remaining' => [ 'label' => 'Show Time Remaining to Player', 'type' => 'select', 'default' => '0', 'required' => true, 'options' => [ '1' => 'Yes', '0' => 'No' ], 'help' => 'If Yes, a timed Status may show how many ticks remain before its next Outcome check.' ] ] ], /* WAYFARER_STORYLET_BUILDER_V56 */ 'storylets' => [ 'label' => 'Storylets', 'singular' => 'Storylet', 'table' => 'wf_storylets', 'list_field' => 'name', 'list_columns' => [ 'name' => [ 'label' => 'Name', 'source' => 'field', 'field' => 'name', 'sortable' => true ], 'parent' => [ 'label' => 'Parent', 'source' => 'relationship', 'relationship' => 'parent_storylet_id', 'sortable' => true ], 'trigger' => [ 'label' => 'Trigger', 'source' => 'field', 'field' => 'trigger_type', 'sortable' => true ], 'status' => [ 'label' => 'Status', 'source' => 'field', 'field' => 'status', 'sortable' => true ] ], /* * A Storylet with children cannot be permanently removed until its * Branch Storylets have been detached or deleted. */ 'delete' => [ 'enabled' => true, 'dependencies' => [ [ 'table' => 'wf_storylets', 'foreign_key' => 'parent_storylet_id', 'label' => 'Branch Storylets' ] ] ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'relationships' => [ 'parent_storylet_id' => [ 'label' => 'Parent Storylet', 'type' => 'select_record', 'table' => 'wf_storylets', 'value_field' => 'id', 'label_field' => 'name', 'game_field' => 'game_id', 'required' => false, 'exclude_current' => true, 'help' => 'Leave blank for a root Storylet. Select a parent to make ' . 'this Storylet one of that Storylet\'s player Branches.' ] ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true, 'help' => 'Author-facing Storylet name and default player-facing title.' ], 'description' => [ 'label' => 'Narrative / Description', 'type' => 'textarea', 'help' => 'Narrative shown when this Storylet opens. It may also be ' . 'used as preview text when the Storylet is offered.' ], 'branch_label' => [ 'label' => 'Branch Label', 'type' => 'text', 'help' => 'Used when this Storylet appears as a child Branch. Leave ' . 'blank to use the Storylet Name.' ], 'branch_sort_order' => [ 'label' => 'Branch Order', 'type' => 'number', 'min' => 0, 'default' => 0, 'help' => 'Lower numbers appear first among sibling Branches. Ignored ' . 'for root Storylets.' ], 'trigger_type' => [ 'label' => 'Trigger Type', 'type' => 'select', 'required' => true, 'default' => 'manual', 'options' => [ 'manual' => 'Manual', 'automatic' => 'Automatic', 'chance' => 'Chance', /* WAYFARER_EVENT_STORYLETS_V88 */ 'event' => 'Event / Linked Only' ], 'help' => 'Manual: offer while eligible. Automatic: fire when it becomes ' . 'eligible. Chance: while eligible, roll the base chance each tick. ' . 'Event / Linked Only: never offer as a normal Action; open it from ' . 'a Status Outcome or another linked gameplay event.' ], 'max_occurrences' => [ 'label' => 'Maximum Occurrences', 'type' => 'number', 'min' => 1, 'help' => 'Leave blank for unlimited occurrences.' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], 'config_fields' => [ 'priority' => [ 'label' => 'Priority', 'type' => 'number', 'default' => 100, 'help' => 'Author-controlled gameplay order for competing Storylets. ' . 'Lower numbers are evaluated first.' ], 'tick_cost' => [ 'label' => 'Tick Cost', 'type' => 'number', 'min' => 0, 'default' => 0, 'help' => 'How many normal gameplay ticks resolving this Storylet consumes. ' . '0 consumes no time; 1 or more runs the normal tick pipeline.' ], 'chance_percent' => [ 'label' => 'Base Chance %', 'type' => 'number', 'min' => 0, 'max' => 100, 'step' => 0.01, 'help' => 'Used only by Chance Storylets. Eligibility is checked first; ' . 'then this percentage is rolled on each gameplay tick.' ], 'interrupts_travel' => [ 'label' => 'Interrupts Travel', 'type' => 'select', 'default' => '0', 'options' => [ '0' => 'No', '1' => 'Yes' ], 'help' => 'Used by Automatic and Chance Storylets. If Yes, firing the ' . 'Storylet stops the current travel activity. Manual Storylets ' . 'are selected by the player and do not use this setting.' ] ] ] ]; ================================================================================ FILE: _wayfarer_backups/v93-20260930-145229/includes/builder/editor.php ================================================================================ $groupValues) { if (!is_array($groupValues)) { continue; } foreach ($groupValues as $traitName => $traitValue) { if (!isset($storedSystemTraitValues[$groupName][$traitName])) { $storedSystemTraitValues[$groupName][$traitName] = $traitValue; } } } } } $renderSystemTraits = function () use ( $record, $fields, $systemTraitsSelector, $systemTraitGroups, $storedSystemTraitValues, $storedSystemTraitMaxValues ) { if (!$systemTraitsSelector || empty($systemTraitGroups)) { return; } $selectedGroup = $record[$systemTraitsSelector] ?? ($fields[$systemTraitsSelector]['default'] ?? ''); ?>
$groupDefinition): ?>
>

$traitDefinition): ?>

>

Storylet duplicated. This copy is saved as Draft.

Copies the saved Storylet into a new Draft. If this is a Branch, the copy becomes a sibling Branch. Child Branches are not copied.
$relationship): ?> $field): ?>

Media

Optional. Reuse an existing asset from this game's Media Library, upload a new asset, or leave the slot empty.

$mediaDefinition): ?>

Maximum upload: .

Current: ()



No existing assets have been uploaded to this game yet.

$field): ?>






Traits

Traits are additional values associated with this that can change during play.


Danger Zone

Permanently delete this . This cannot be undone.

================================================================================ FILE: _wayfarer_backups/v93-20260930-145229/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects, array $storyletBindings = [], int $currentStoryletId = 0 ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $allowedTargets = array_replace( gameplayRuntimeStateTargets( $pdo, $gameId ), gameplayStoryletBindingEffectTargets( $pdo, $gameId, $storyletBindings ), gameplayStoryletNavigationEffectTargets( $pdo, $gameId, $currentStoryletId ) ); $result = gameplayBuildEffectsFromPost( $allowedTargets, $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); if (($result['error'] ?? '') !== '') { return $result; } $transitionCount = 0; foreach ( $result['effects'] ?? [] as $effect ) { if ( is_array($effect) && (string)( $effect['target_scope'] ?? '' ) === 'storylet' && (string)( $effect['target_type'] ?? '' ) === 'transition' ) { $transitionCount++; } } if ($transitionCount > 1) { return [ 'effects' => [], 'error' => 'A Storylet may have only one Storylet → Continue To Effect.', ]; } return $result; } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /* * WAYFARER_LINEAR_TRAVEL_SAVE_V72 * * Dedicated Linear Travel is deliberately opt-in. A null travel result means * the Region should continue using the legacy Position Tick Effect path. */ function buildRegionTravelFromPost( array $record, array $postedTravel ): array { $navigationType = strtolower( trim( (string)( $record['navigation_type'] ?? '' ) ) ); if ($navigationType !== 'linear') { return [ 'travel' => null, 'error' => '', ]; } if (empty($postedTravel['enabled'])) { return [ 'travel' => null, 'error' => '', ]; } $baseDistance = $postedTravel['base_distance'] ?? ''; if ( !is_numeric($baseDistance) || (float)$baseDistance <= 0 ) { return [ 'travel' => null, 'error' => 'Base Distance Per Tick must be greater than zero.', ]; } $baseDistance = (float)$baseDistance; if (floor($baseDistance) === $baseDistance) { $baseDistance = (int)$baseDistance; } return [ 'travel' => [ 'enabled' => true, 'base_distance' => $baseDistance, 'allow_turnaround' => !empty( $postedTravel[ 'allow_turnaround' ] ), ], 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Storylet duplication | WAYFARER_STORYLET_DUPLICATE_V83 |-------------------------------------------------------------------------- | | Duplicate the currently saved Storylet as a Draft. The duplicate copies | the selected Storylet record and its media links, but intentionally does | not recursively copy child Branch Storylets. | */ function duplicateStoryletRecord( PDO $pdo, int $gameId, int $storyletId ): array { if ($gameId <= 0 || $storyletId <= 0) { return [ 'duplicated' => false, 'id' => 0, 'error' => 'A valid Storylet is required to duplicate.', ]; } try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, uuid, game_id, name, storylet_key, description, conditions, effects, max_occurrences, trigger_type, status, config, parent_storylet_id, choice_label, choice_sort_order, branch_label, branch_sort_order FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $storyletId, $gameId, ]); $source = $stmt->fetch(PDO::FETCH_ASSOC); if (!$source) { throw new RuntimeException( 'The Storylet to duplicate could not be found.' ); } $sourceName = trim( (string)( $source['name'] ?? 'Storylet' ) ); if ($sourceName === '') { $sourceName = 'Storylet'; } $baseName = preg_replace( '/\s+-\s+COPY(?:\s+\d+)?$/i', '', $sourceName ); if (!is_string($baseName) || trim($baseName) === '') { $baseName = $sourceName; } $baseName = trim($baseName); /* Leave room for " - COPY 9999" within the 200-character Name. */ $baseName = function_exists('mb_substr') ? mb_substr($baseName, 0, 185, 'UTF-8') : substr($baseName, 0, 185); $copyName = $baseName . ' - COPY'; $copyNumber = 2; $nameCheck = $pdo->prepare( " SELECT 1 FROM wf_storylets WHERE game_id = ? AND name = ? LIMIT 1 " ); while (true) { $nameCheck->execute([ $gameId, $copyName, ]); if (!$nameCheck->fetchColumn()) { break; } $copyName = $baseName . ' - COPY ' . $copyNumber; $copyNumber++; if ($copyNumber > 9999) { throw new RuntimeException( 'Could not generate a unique Storylet copy name.' ); } } $sourceKey = trim( (string)( $source['storylet_key'] ?? '' ) ); if ($sourceKey === '') { $sourceKey = 'storylet-' . $storyletId; } $copyKey = substr( $sourceKey, 0, 80 ) . '-copy-' . bin2hex( random_bytes(6) ); $copyUuid = bin2hex( random_bytes(16) ); $parentId = (int)( $source['parent_storylet_id'] ?? 0 ); $branchSortOrder = (int)( $source['branch_sort_order'] ?? 0 ); $choiceSortOrder = (int)( $source['choice_sort_order'] ?? 0 ); $branchLabel = (string)( $source['branch_label'] ?? '' ); $choiceLabel = (string)( $source['choice_label'] ?? '' ); if ($parentId > 0) { $orderStmt = $pdo->prepare( " SELECT COALESCE(MAX(branch_sort_order), 0) FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? " ); $orderStmt->execute([ $gameId, $parentId, ]); $branchSortOrder = (int)$orderStmt->fetchColumn() + 10; $choiceSortOrder = $branchSortOrder; if (trim($branchLabel) !== '') { $branchLabel = rtrim($branchLabel) . ' - COPY'; } if (trim($choiceLabel) !== '') { $choiceLabel = rtrim($choiceLabel) . ' - COPY'; } } $insert = $pdo->prepare( " INSERT INTO wf_storylets ( uuid, game_id, name, storylet_key, description, conditions, effects, max_occurrences, trigger_type, status, config, parent_storylet_id, choice_label, choice_sort_order, branch_label, branch_sort_order ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?) " ); $insert->execute([ $copyUuid, $gameId, $copyName, $copyKey, $source['description'] ?? null, $source['conditions'] ?? null, $source['effects'] ?? null, $source['max_occurrences'] ?? null, $source['trigger_type'] ?? 'manual', $source['config'] ?? null, $parentId > 0 ? $parentId : null, $choiceLabel !== '' ? $choiceLabel : null, $choiceSortOrder, $branchLabel, $branchSortOrder, ]); $copyId = (int)$pdo->lastInsertId(); if ($copyId <= 0) { throw new RuntimeException( 'The Storylet copy could not be created.' ); } /* Reuse the same game-level media assets; do not duplicate files. */ $media = $pdo->prepare( " INSERT INTO wf_media_links ( media_id, object_type, object_id, slot ) SELECT media_id, object_type, ?, slot FROM wf_media_links WHERE object_type = 'wf_storylets' AND object_id = ? " ); $media->execute([ $copyId, $storyletId, ]); $pdo->commit(); return [ 'duplicated' => true, 'id' => $copyId, 'error' => '', ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } return [ 'duplicated' => false, 'id' => 0, 'error' => $exception->getMessage(), ]; } } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_LINEAR_TRAVEL_SAVE_V72 */ $regionTravel = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionTravelResult = buildRegionTravelFromPost( $record, isset($post['travel']) && is_array($post['travel']) ? $post['travel'] : [] ); $regionTravel = $regionTravelResult['travel'] ?? null; if ( ($regionTravelResult['error'] ?? '') !== '' ) { $error = (string)$regionTravelResult['error']; } } /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STATUS_LIFECYCLE_SAVE_V88 */ /* WAYFARER_STATUS_OWNER_SAVE_V92 */ $statusLifecycle = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_statuses' ) { $statusLifecycleResult = gameplayBuildStatusLifecycleFromPost( $pdo, $gameId, $post['status_lifecycle'] ?? [], (string)($configValues['applies_to'] ?? 'character') ); $statusLifecycle = $statusLifecycleResult['lifecycle'] ?? null; $record['_status_lifecycle_editor'] = is_array( $statusLifecycleResult['editor'] ?? null ) ? $statusLifecycleResult['editor'] : []; if ( ($statusLifecycleResult['error'] ?? '') !== '' ) { $error = (string)$statusLifecycleResult['error']; } } /* WAYFARER_STORYLET_PARSE_V56 */ /* WAYFARER_STORYLET_BINDINGS_V79 */ $storyletBindings = []; $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $bindingResult = gameplayBuildStoryletBindingsFromPost( isset($post['bindings']) && is_array($post['bindings']) ? array_values($post['bindings']) : [] ); $storyletBindings = $bindingResult['bindings']; if ($bindingResult['error'] !== '') { $error = $bindingResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [], $storyletBindings, $postedId ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_LINEAR_TRAVEL_SAVE_V72 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTravel !== null) { $configToSave['travel'] = $regionTravel; } else { unset( $configToSave['travel'] ); } } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STATUS_LIFECYCLE_CONFIG_V88 */ if ( ($definition['table'] ?? '') === 'wf_statuses' ) { if (is_array($statusLifecycle)) { $configToSave['lifecycle'] = $statusLifecycle; } else { unset($configToSave['lifecycle']); } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { /* WAYFARER_STORYLET_BINDINGS_V79 */ if ($storyletBindings) { $configToSave['bindings'] = array_values($storyletBindings); } else { unset($configToSave['bindings']); } if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } /* * WAYFARER_STORYLET_INTERNAL_IDS_V91 * * uuid and storylet_key are structural Storylet identifiers, not * author-facing Builder fields. The generic INSERT therefore has to add * them explicitly for a new wf_storylets row. */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { $storyletUuid = bin2hex( random_bytes(16) ); $storyletKey = 'storylet-' . substr( $storyletUuid, 0, 24 ); $columns[] = '`uuid`'; $placeholders[] = '?'; $values[] = $storyletUuid; $columns[] = '`storylet_key`'; $placeholders[] = '?'; $values[] = $storyletKey; /* * Keep returned editor state coherent even though these values are * intentionally not displayed as authored fields. */ $record['uuid'] = $storyletUuid; $record['storylet_key'] = $storyletKey; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; /* * WAYFARER_STORYLET_UUID_SAVE_V90 * * Storylet UUID is an internal persistent identifier, not an authored * form value. Older/new-record builder state can therefore carry an * empty string here. Never send that empty value to the UNIQUE * wf_storylets.uuid column; create the identifier at insert time. * * Existing Storylet edits keep their stored UUID unchanged because * this branch runs only in the new-record INSERT path. */ if ( ($definition['table'] ?? '') === 'wf_storylets' && $fieldName === 'uuid' && trim((string)$value) === '' ) { $value = bin2hex( random_bytes(16) ); $record[$fieldName] = $value; } if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: _wayfarer_backups/v93-20260930-145229/includes/traits/functions.php ================================================================================ [...], * 'error' => '' * ] */ function buildTraitsFromPost( array $postedTraits, array $allowedDisplaySurfaces = ['inventory', 'status'] ): array { $traits = []; $error = ''; foreach ($postedTraits as $postedTrait) { $name = trim( $postedTrait['name'] ?? '' ); $type = $postedTrait['type'] ?? 'number'; /* * WAYFARER_TRAIT_PLAYER_DISPLAY_V41 * * Presentation metadata is independent from runtime trait state. */ $postedDisplaySurfaces = $postedTrait['display_surfaces'] ?? []; if (!is_array($postedDisplaySurfaces)) { $postedDisplaySurfaces = []; } $displaySurfaces = []; $allowedDisplaySurfaces = array_values( array_intersect( ['inventory', 'status'], $allowedDisplaySurfaces ) ); foreach ($postedDisplaySurfaces as $surface) { $surface = (string)$surface; if ( in_array( $surface, $allowedDisplaySurfaces, true ) && !in_array( $surface, $displaySurfaces, true ) ) { $displaySurfaces[] = $surface; } } /* * WAYFARER_STATUS_TRAIT_DISPLAY_V86 * * Presentation metadata is stored with the Trait definition. Existing * Traits that do not have this field remain Number/Value by default. */ $statusDisplay = strtolower( trim( (string)( $postedTrait['status_display'] ?? 'number' ) ) ); if ( !in_array( $statusDisplay, ['number', 'bar', 'both'], true ) ) { $error = $name !== '' ? $name . ': invalid Status display mode.' : 'Invalid Status display mode.'; break; } // Ignore completely blank rows. if ($name === '') { continue; } // Validate type. if (!in_array( $type, ['number', 'boolean'], true )) { $error = 'Invalid trait type.'; break; } // Preserve an existing ID. // Generate one only for a new trait. $traitId = trim( $postedTrait['id'] ?? '' ); if ($traitId === '') { $traitId = 'tr_' . bin2hex(random_bytes(8)); } $trait = [ 'id' => $traitId, 'name' => $name, 'type' => $type, 'active' => ($postedTrait['active'] ?? '1') === '1', 'notes' => trim($postedTrait['notes'] ?? ''), 'display' => [ 'surfaces' => $displaySurfaces, 'status_mode' => $statusDisplay, ] ]; // Boolean if ($type === 'boolean') { /* * Bars are only meaningful for bounded numeric Traits. * If an old/cached form submits another mode for a Boolean, retain * the Trait and normalize its presentation to Number/Value. */ $trait['display']['status_mode'] = 'number'; $trait['initial'] = ($postedTrait['initial_boolean'] ?? '0') === '1'; } // Number else { $min = trim( $postedTrait['min'] ?? '' ); $max = trim( $postedTrait['max'] ?? '' ); $initial = trim( $postedTrait['initial_number'] ?? '' ); if ($min !== '' && !is_numeric($min)) { $error = $name . ': minimum must be a number.'; break; } if ($max !== '' && !is_numeric($max)) { $error = $name . ': maximum must be a number.'; break; } if ( $initial !== '' && !is_numeric($initial) ) { $error = $name . ': initial value must be a number.'; break; } $minValue = $min === '' ? null : (float)$min; $maxValue = $max === '' ? null : (float)$max; $initialValue = $initial === '' ? 0 : (float)$initial; if ( $minValue !== null && $maxValue !== null && $minValue > $maxValue ) { $error = $name . ': minimum cannot be greater than maximum.'; break; } if ( $minValue !== null && $initialValue < $minValue ) { $error = $name . ': initial value cannot be less than minimum.'; break; } if ( $maxValue !== null && $initialValue > $maxValue ) { $error = $name . ': initial value cannot be greater than maximum.'; break; } if ( in_array( $statusDisplay, ['bar', 'both'], true ) && ( $minValue === null || $maxValue === null || $maxValue <= $minValue ) ) { $error = $name . ': Bar Status display requires both Minimum and Maximum, ' . 'with Maximum greater than Minimum.'; break; } $trait['min'] = $minValue; $trait['max'] = $maxValue; $trait['initial'] = $initialValue; } $traits[] = $trait; } return [ 'traits' => $traits, 'error' => $error ]; } ================================================================================ FILE: _wayfarer_backups/v93-20260930-145229/includes/traits/render.php ================================================================================

$trait): ?> (float)$trait['min']; if ( !$traitStatusBarAvailable && $traitStatusMode !== 'number' ) { $traitStatusMode = 'number'; } ?>
Name Type Minimum Maximum Initial Value Status
— —
Show to Player: Bars require a Number Trait with both Minimum and Maximum. True / False Traits always display their value. Controls where this trait is displayed to the player. Unchecked traits remain available to gameplay logic.

[ * TRAIT_ID => CURRENT_VALUE, * ], * 'discovered' => true|false, // Items only * ]; * * The helper is intentionally additive. Existing state wins. */ function wayfarerEntityDecodeConfig($rawConfig): array { if (is_array($rawConfig)) { return $rawConfig; } if ( $rawConfig === null || $rawConfig === '' ) { return []; } $decoded = json_decode( (string)$rawConfig, true ); return is_array($decoded) ? $decoded : []; } function wayfarerEntityActiveTraitDefinitions( array $config, string $entityType = '' ): array { $definitions = []; /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Rehydrate only mutable universal Entity state. Static presentation rules * such as inventory_visibility remain authored configuration and are not * copied into wf_game_users.state. */ $system = isset($config['system']) && is_array($config['system']) ? $config['system'] : []; $entityType = strtolower( trim($entityType) ); if ($entityType === 'item') { $quantity = isset($system['quantity']) && is_array($system['quantity']) ? $system['quantity'] : []; $definitions['sys_item_quantity'] = [ 'id' => 'sys_item_quantity', 'name' => 'quantity', 'type' => 'number', 'min' => 0, 'max' => $quantity['max'] ?? null, 'initial' => $quantity['initial'] ?? 0, 'active' => true, 'system' => true, 'system_group' => 'item', 'system_key' => 'quantity', '_runtime_id' => 'sys_item_quantity', ]; } if ($entityType === 'character') { $definitions['sys_character_party_member'] = [ 'id' => 'sys_character_party_member', 'name' => 'party_member', 'type' => 'binary', 'min' => 0, 'max' => 1, 'initial' => (int)($system['party_member'] ?? 0), 'active' => true, 'system' => true, 'system_group' => 'character', 'system_key' => 'party_member', '_runtime_id' => 'sys_character_party_member', ]; } $traits = $config['traits'] ?? []; if (!is_array($traits)) { $traits = []; } foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( array_key_exists( 'active', $trait ) && !$trait['active'] ) { continue; } /* * Legacy safety: if an unmigrated V49-V54 system Trait is encountered, * do not allow it to override the canonical V55 definition. */ if (!empty($trait['system'])) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $trait['_runtime_id'] = $traitId; $definitions[$traitId] = $trait; } return $definitions; } function wayfarerEntitySystemConfigValue( array $config, string $key, $default = null ) { $system = isset($config['system']) && is_array($config['system']) ? $config['system'] : []; return array_key_exists($key, $system) ? $system[$key] : $default; } function wayfarerEntitySystemTraitId( array $definitions, string $systemKey ): ?string { foreach ( $definitions as $traitId => $definition ) { if ( !is_array($definition) || empty($definition['system']) ) { continue; } if ( (string)( $definition['system_key'] ?? '' ) === $systemKey ) { return (string)$traitId; } } return null; } function wayfarerEntityTraitShownOn( array $definition, string $surface ): bool { if (!empty($definition['system'])) { return false; } $surfaces = $definition['display']['surfaces'] ?? []; return is_array($surfaces) && in_array( $surface, $surfaces, true ); } function wayfarerEntityDisplayValue($value): string { if (is_bool($value)) { return $value ? 'Yes' : 'No'; } if ($value === null) { return '—'; } if (is_array($value)) { $json = json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); return $json === false ? '—' : $json; } return (string)$value; } function wayfarerRuntimeLoadActiveEntities( PDO $pdo, int $gameId ): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare( " SELECT id, name, description, entity_type, status, config FROM wf_entities WHERE game_id = ? AND status = 'active' ORDER BY name, id " ); $stmt->execute([ $gameId ]); $rows = $stmt->fetchAll( PDO::FETCH_ASSOC ); return is_array($rows) ? $rows : []; } /* * Additive/recovery-safe Entity initialization. * * Returns true only when runtime state was changed. */ function wayfarerRuntimeEnsureEntityState( PDO $pdo, int $gameId, array &$runtimeState ): bool { if ($gameId <= 0) { return false; } $changed = false; if ( !isset($runtimeState['entities']) || !is_array( $runtimeState['entities'] ) ) { $runtimeState['entities'] = []; $changed = true; } foreach ( wayfarerRuntimeLoadActiveEntities( $pdo, $gameId ) as $entity ) { $entityId = (int)( $entity['id'] ?? 0 ); if ($entityId <= 0) { continue; } $entityKey = (string)$entityId; $config = wayfarerEntityDecodeConfig( $entity['config'] ?? null ); $entityType = strtolower( trim( (string)( $entity['entity_type'] ?? '' ) ) ); $definitions = wayfarerEntityActiveTraitDefinitions( $config, $entityType ); if ( !isset( $runtimeState['entities'][$entityKey] ) || !is_array( $runtimeState['entities'][$entityKey] ) ) { $runtimeState['entities'][$entityKey] = []; $changed = true; } if ( !isset( $runtimeState['entities'][$entityKey]['traits'] ) || !is_array( $runtimeState['entities'][$entityKey]['traits'] ) ) { $runtimeState['entities'][$entityKey]['traits'] = []; $changed = true; } foreach ( $definitions as $traitId => $definition ) { if ( array_key_exists( $traitId, $runtimeState['entities'][$entityKey]['traits'] ) ) { continue; } /* * Reuse Wayfarer's canonical initial-value semantics. */ $runtimeState['entities'][$entityKey]['traits'][$traitId] = wayfarerRuntimeInitialTraitValue( $definition ); $changed = true; } if ( strtolower( trim( (string)( $entity['entity_type'] ?? '' ) ) ) !== 'item' ) { continue; } $quantityTraitId = wayfarerEntitySystemTraitId( $definitions, 'quantity' ); $quantity = $quantityTraitId !== null ? ( $runtimeState['entities'][$entityKey]['traits'] [$quantityTraitId] ?? 0 ) : 0; /* * Discovery is persistent. Possessing the Item at any recovery point * discovers it; returning to zero later never clears discovery. */ if ( !array_key_exists( 'discovered', $runtimeState['entities'][$entityKey] ) ) { $runtimeState['entities'][$entityKey]['discovered'] = is_numeric($quantity) && (float)$quantity > 0; $changed = true; } elseif ( !$runtimeState['entities'][$entityKey]['discovered'] && is_numeric($quantity) && (float)$quantity > 0 ) { $runtimeState['entities'][$entityKey]['discovered'] = true; $changed = true; } } return $changed; } /* * Resolve the player-facing Inventory and Status models. * * Presentation metadata never changes whether a Trait exists in runtime. */ /* * WAYFARER_ENTITY_DETAIL_VIEW_V50 * * Authored media links use the underlying table name as object_type. * Entity images therefore use object_type = wf_entities. */ function wayfarerEntityImageUrl( PDO $pdo, int $gameId, int $entityId ): string { if ( $gameId <= 0 || $entityId <= 0 ) { return ''; } $stmt = $pdo->prepare( " SELECT m.relative_path FROM wf_media_links ml INNER JOIN wf_media m ON m.id = ml.media_id WHERE ml.object_type = 'wf_entities' AND ml.object_id = ? AND ml.slot = 'image' AND m.game_id = ? AND m.media_type = 'image' LIMIT 1 " ); $stmt->execute([ $entityId, $gameId, ]); $relativePath = $stmt->fetchColumn(); if ( $relativePath === false || $relativePath === null || $relativePath === '' ) { return ''; } $relativePath = ltrim( str_replace( '\\', '/', (string)$relativePath ), '/' ); /* * Media uploads live beneath the Wayfarer root. play/ is one level below. * Reject traversal rather than attempting to normalize it. */ if ( $relativePath === '' || strpos( $relativePath, '..' ) !== false ) { return ''; } return '../' . $relativePath; } /* * WAYFARER_STATUS_TRAIT_DISPLAY_V86 * * Builds presentation metadata for a Trait shown on the Status screen. * Existing Traits default to Number/Value. Bar modes require a bounded * numeric Trait; malformed/legacy metadata fails safely back to Number. */ function wayfarerRuntimeStatusTraitPresentation( array $definition, $value ): array { $mode = strtolower( trim( (string)( $definition['display']['status_mode'] ?? 'number' ) ) ); if ( !in_array( $mode, ['number', 'bar', 'both'], true ) ) { $mode = 'number'; } $type = strtolower( trim( (string)( $definition['type'] ?? '' ) ) ); if ($type === 'binary') { $type = 'boolean'; } $minimum = $definition['min'] ?? null; $maximum = $definition['max'] ?? null; $barAvailable = $type === 'number' && is_numeric($value) && is_numeric($minimum) && is_numeric($maximum) && (float)$maximum > (float)$minimum; $barPercent = null; if ($barAvailable) { $barPercent = ( ( (float)$value - (float)$minimum ) / ( (float)$maximum - (float)$minimum ) ) * 100; $barPercent = max( 0, min( 100, $barPercent ) ); } elseif ( $mode === 'bar' || $mode === 'both' ) { $mode = 'number'; } return [ 'value' => wayfarerEntityDisplayValue( $value ), 'display_mode' => $mode, 'bar_available' => $barAvailable, 'bar_percent' => $barPercent, 'bar_min' => $barAvailable ? (float)$minimum : null, 'bar_max' => $barAvailable ? (float)$maximum : null, 'bar_value' => $barAvailable ? (float)$value : null, ]; } function wayfarerRuntimeBuildEntityPresentation( PDO $pdo, int $gameId, array $runtimeState, array $gameConfig = [] ): array { $result = [ 'inventory' => [], 'party' => [], ]; /* * WAYFARER_PLAYER_TRAITS_RUNTIME_V52 * Game and built-in Player presentation now lives in player-state.php. * This resolver owns only Item Inventory and joined Character Entities. */ foreach ( wayfarerRuntimeLoadActiveEntities( $pdo, $gameId ) as $entity ) { $entityId = (int)( $entity['id'] ?? 0 ); if ($entityId <= 0) { continue; } $entityKey = (string)$entityId; $entityState = $runtimeState['entities'][$entityKey] ?? []; $traitState = is_array( $entityState['traits'] ?? null ) ? $entityState['traits'] : []; $config = wayfarerEntityDecodeConfig( $entity['config'] ?? null ); $entityType = strtolower( trim( (string)( $entity['entity_type'] ?? '' ) ) ); $definitions = wayfarerEntityActiveTraitDefinitions( $config, $entityType ); /* * -------------------------------------------------------------- * INVENTORY * -------------------------------------------------------------- */ if ($entityType === 'item') { $quantityTraitId = wayfarerEntitySystemTraitId( $definitions, 'quantity' ); $quantity = $quantityTraitId !== null ? ( $traitState[$quantityTraitId] ?? 0 ) : 0; /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * Inventory visibility is authored presentation configuration, * not mutable playthrough state. */ $visibility = (string) wayfarerEntitySystemConfigValue( $config, 'inventory_visibility', 'while_possessed' ); $discovered = !empty( $entityState['discovered'] ); $show = false; switch ($visibility) { case 'always': $show = true; break; case 'never': $show = false; break; case 'once_discovered': $show = $discovered; break; case 'while_possessed': default: $show = is_numeric($quantity) && (float)$quantity > 0; break; } if (!$show) { continue; } $visibleTraits = []; foreach ( $definitions as $traitId => $definition ) { if ( !wayfarerEntityTraitShownOn( $definition, 'inventory' ) || !array_key_exists( $traitId, $traitState ) ) { continue; } $visibleTraits[] = [ 'id' => $traitId, 'name' => (string)( $definition['name'] ?? $traitId ), 'value' => wayfarerEntityDisplayValue( $traitState[$traitId] ), ]; } $result['inventory'][] = [ 'id' => $entityId, 'name' => (string)( $entity['name'] ?? 'Item' ), 'description' => trim( (string)( $entity['description'] ?? '' ) ), 'image_url' => wayfarerEntityImageUrl( $pdo, $gameId, $entityId ), 'quantity' => wayfarerEntityDisplayValue( $quantity ), 'traits' => $visibleTraits, ]; continue; } /* * -------------------------------------------------------------- * STATUS / PARTY * -------------------------------------------------------------- */ if ($entityType === 'character') { $partyTraitId = wayfarerEntitySystemTraitId( $definitions, 'party_member' ); $inParty = $partyTraitId !== null && !empty( $traitState[$partyTraitId] ); if (!$inParty) { continue; } $visibleTraits = []; foreach ( $definitions as $traitId => $definition ) { if ( !wayfarerEntityTraitShownOn( $definition, 'status' ) || !array_key_exists( $traitId, $traitState ) ) { continue; } $traitPresentation = wayfarerRuntimeStatusTraitPresentation( $definition, $traitState[$traitId] ); $visibleTraits[] = array_merge( [ 'id' => $traitId, 'name' => (string)( $definition['name'] ?? $traitId ), ], $traitPresentation ); } $result['party'][] = [ 'id' => $entityId, 'name' => (string)( $entity['name'] ?? 'Character' ), 'description' => trim( (string)( $entity['description'] ?? '' ) ), 'image_url' => wayfarerEntityImageUrl( $pdo, $gameId, $entityId ), 'traits' => $visibleTraits, ]; } } return $result; } ================================================================================ FILE: _wayfarer_backups/v93-20260930-145229/play/includes/runtime.php ================================================================================ $mode, 'starting_day' => max(1, (int)($raw['starting_day'] ?? 1)), 'ticks_per_day' => max(1, (int)($raw['ticks_per_day'] ?? 1)), 'starting_hour' => max(0, (int)($raw['starting_hour'] ?? 8)), 'ticks_per_hour' => max(1, (int)($raw['ticks_per_hour'] ?? 1)), 'hours_per_day' => max(1, (int)($raw['hours_per_day'] ?? 24)), ]; if ($config['starting_hour'] >= $config['hours_per_day']) { $config['starting_hour'] = $config['hours_per_day'] - 1; } return $config; } function wayfarerRuntimeInitializeClockFromConfig( array $gameConfig, array &$runtimeState ): void { if (!isset($runtimeState['runtime']) || !is_array($runtimeState['runtime'])) { $runtimeState['runtime'] = []; } $config = wayfarerRuntimeTimeConfig($gameConfig); if ($config['mode'] === 'none') { unset($runtimeState['runtime']['clock']); return; } $clock = [ 'mode' => $config['mode'], 'day' => $config['starting_day'], ]; if ($config['mode'] === 'days') { $clock['tick_in_day'] = 0; } else { $clock['hour'] = $config['starting_hour']; $clock['tick_in_hour'] = 0; } $runtimeState['runtime']['clock'] = $clock; } function wayfarerRuntimeLoadGameConfig(PDO $pdo, int $gameId): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare('SELECT config FROM wf_games WHERE id = ? LIMIT 1'); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); return wayfarerRuntimeDecodeConfig($raw === false ? null : $raw); } function wayfarerRuntimeEnsureClock( PDO $pdo, int $gameId, array &$runtimeState ): array { $gameConfig = wayfarerRuntimeLoadGameConfig($pdo, $gameId); $timeConfig = wayfarerRuntimeTimeConfig($gameConfig); $clock = $runtimeState['runtime']['clock'] ?? null; if ($timeConfig['mode'] === 'none') { unset($runtimeState['runtime']['clock']); return $timeConfig; } if (!is_array($clock) || (string)($clock['mode'] ?? '') !== $timeConfig['mode']) { wayfarerRuntimeInitializeClockFromConfig($gameConfig, $runtimeState); } return $timeConfig; } function wayfarerRuntimeAdvanceClock( PDO $pdo, int $gameId, array &$runtimeState ): void { $config = wayfarerRuntimeEnsureClock($pdo, $gameId, $runtimeState); if ($config['mode'] === 'none') { return; } $clock =& $runtimeState['runtime']['clock']; $clock['day'] = max(1, (int)($clock['day'] ?? $config['starting_day'])); if ($config['mode'] === 'days') { $clock['tick_in_day'] = max(0, (int)($clock['tick_in_day'] ?? 0)) + 1; if ($clock['tick_in_day'] >= $config['ticks_per_day']) { $clock['day']++; $clock['tick_in_day'] = 0; } return; } $clock['hour'] = max(0, (int)($clock['hour'] ?? $config['starting_hour'])); $clock['tick_in_hour'] = max(0, (int)($clock['tick_in_hour'] ?? 0)) + 1; if ($clock['tick_in_hour'] >= $config['ticks_per_hour']) { $clock['tick_in_hour'] = 0; $clock['hour']++; if ($clock['hour'] >= $config['hours_per_day']) { $clock['hour'] = 0; $clock['day']++; } } } function wayfarerRuntimeClockLabel(array $runtimeState, array $timeConfig): string { if ($timeConfig['mode'] === 'none') { return ''; } $clock = $runtimeState['runtime']['clock'] ?? []; $day = max(1, (int)($clock['day'] ?? $timeConfig['starting_day'])); if ($timeConfig['mode'] === 'days') { return 'Day ' . $day; } $hour = max(0, (int)($clock['hour'] ?? $timeConfig['starting_hour'])); if ($timeConfig['hours_per_day'] === 24) { $displayHour = $hour % 12; if ($displayHour === 0) { $displayHour = 12; } $suffix = $hour < 12 ? 'AM' : 'PM'; return 'Day ' . $day . ' · ' . $displayHour . ':00 ' . $suffix; } return 'Day ' . $day . ' · Hour ' . $hour; } /* * WAYFARER_BIDIRECTIONAL_TRAVEL_V71 * * Runtime direction is journey state, not Region authoring state. +1 follows * the Region's authored forward Tick Effect; -1 reverses it. Keeping the * authored Add/Subtract sign as the base preserves existing Regions while * allowing the same line to be traversed in either direction. */ function wayfarerRuntimeNavigationDirection(array $runtimeState): int { $direction = (int)( $runtimeState['navigation']['direction'] ?? 1 ); return $direction < 0 ? -1 : 1; } /* * WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 * * Returns canonical dedicated travel configuration, or null for Regions that * still use the legacy Position Tick Effect movement path. */ function wayfarerRuntimeDedicatedLinearTravel( array $regionConfig ): ?array { $travel = isset($regionConfig['travel']) && is_array($regionConfig['travel']) ? $regionConfig['travel'] : null; if ( $travel === null || empty($travel['enabled']) || !isset($travel['base_distance']) || !is_numeric($travel['base_distance']) || (float)$travel['base_distance'] <= 0 ) { return null; } return [ 'base_distance' => (float)$travel['base_distance'], 'allow_turnaround' => !empty( $travel['allow_turnaround'] ), ]; } /* WAYFARER_GAME_TRAVEL_MODIFIERS_MOVEMENT_V73 */ function wayfarerRuntimeApplyDedicatedLinearTravelMovement( array &$runtimeState, array $travel, float $modifierAdjustment = 0.0, array $matchedModifiers = [] ): array { if ( !isset($runtimeState['navigation']) || !is_array($runtimeState['navigation']) ) { $runtimeState['navigation'] = []; } $before = $runtimeState['navigation']['position'] ?? 0; if (!is_numeric($before)) { return [ 'scope' => 'navigation', 'type' => 'position', 'target_id' => 'position', 'applied' => false, 'error' => 'Navigation position must be numeric.', ]; } $baseDistance = max( 0.0, (float)( $travel['base_distance'] ?? 0 ) ); /* * Modifiers may slow travel to zero, but never reverse the player's * chosen direction. Direction is applied only after effective distance is * clamped to a non-negative value. */ $distance = max( 0.0, $baseDistance + $modifierAdjustment ); $direction = wayfarerRuntimeNavigationDirection( $runtimeState ); $after = max( 0.0, (float)$before + ($distance * $direction) ); $after = wayfarerNormalizeRuntimeNumber( $after ); $runtimeState['navigation']['position'] = $after; return [ 'scope' => 'navigation', 'type' => 'position', 'target_id' => 'position', 'operation' => 'travel', 'base_distance' => wayfarerNormalizeRuntimeNumber( $baseDistance ), 'modifier_adjustment' => wayfarerNormalizeRuntimeNumber( $modifierAdjustment ), 'matched_modifiers' => array_values( $matchedModifiers ), 'amount' => wayfarerNormalizeRuntimeNumber( $distance ), 'direction' => $direction, 'before' => wayfarerNormalizeRuntimeNumber( (float)$before ), 'after' => $after, 'applied' => true, 'error' => '', ]; } function wayfarerRuntimeLinearTravelDirection( array $regionConfig, array $runtimeState ): int { $runtimeDirection = wayfarerRuntimeNavigationDirection( $runtimeState ); /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ if ( wayfarerRuntimeDedicatedLinearTravel( $regionConfig ) !== null ) { return $runtimeDirection; } $effects = isset($regionConfig['tick_effects']) && is_array($regionConfig['tick_effects']) ? $regionConfig['tick_effects'] : []; foreach ($effects as $effect) { if (!is_array($effect)) { continue; } if ( (string)($effect['target_scope'] ?? '') !== 'navigation' || (string)($effect['target_type'] ?? '') !== 'position' || (string)($effect['target_id'] ?? '') !== 'position' ) { continue; } $branches = wayfarerRuntimeTickEffectBranches($effect); foreach ($branches as $branch) { $operation = strtolower(trim((string)($branch['operation'] ?? ''))); if ($operation === 'subtract') { return -1 * $runtimeDirection; } if ($operation === 'add') { return 1 * $runtimeDirection; } } } return $runtimeDirection; } function wayfarerRuntimeBuildTravelStatus( PDO $pdo, int $gameId, array &$runtimeState ): array { $gameConfig = wayfarerRuntimeLoadGameConfig($pdo, $gameId); $timeConfig = wayfarerRuntimeEnsureClock($pdo, $gameId, $runtimeState); $result = [ 'time_label' => wayfarerRuntimeClockLabel($runtimeState, $timeConfig), 'region_name' => '', 'distance_label' => 'units', 'next_location_id' => 0, 'next_location_name' => '', 'distance_to_next' => null, 'next_label' => '', /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ 'dedicated_travel' => false, 'base_distance_per_tick' => null, 'allow_turnaround' => false, 'can_turn_around' => false, 'direction' => 1, ]; $regionId = (int)($runtimeState['navigation']['region_id'] ?? 0); $position = $runtimeState['navigation']['position'] ?? null; if ($regionId <= 0 || !is_numeric($position)) { return $result; } $stmt = $pdo->prepare( 'SELECT id, name, navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1' ); $stmt->execute([$regionId, $gameId]); $region = $stmt->fetch(PDO::FETCH_ASSOC); if (!$region) { return $result; } $result['region_name'] = trim((string)($region['name'] ?? '')); if (strtolower(trim((string)($region['navigation_type'] ?? ''))) !== 'linear') { return $result; } $regionConfig = wayfarerRuntimeDecodeConfig($region['config'] ?? null); $distanceLabel = trim((string)($regionConfig['distance_label'] ?? 'units')); $result['distance_label'] = $distanceLabel !== '' ? $distanceLabel : 'units'; $direction = wayfarerRuntimeLinearTravelDirection($regionConfig, $runtimeState); $current = (float)$position; /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ $dedicatedTravel = wayfarerRuntimeDedicatedLinearTravel( $regionConfig ); $result['direction'] = $direction; if ($dedicatedTravel !== null) { $result['dedicated_travel'] = true; $result['base_distance_per_tick'] = wayfarerNormalizeRuntimeNumber( (float)$dedicatedTravel[ 'base_distance' ] ); $result['allow_turnaround'] = !empty( $dedicatedTravel[ 'allow_turnaround' ] ); /* * Do not offer a reversal that would immediately point out of the * legal lower boundary. If already traveling toward Start at zero, * turning around toward End remains valid. */ $result['can_turn_around'] = $result['allow_turnaround'] && !( $current <= 0 && $direction > 0 ); } $locationStmt = $pdo->prepare( "SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id" ); $locationStmt->execute([$regionId]); $best = null; $bestDistance = null; foreach ($locationStmt->fetchAll(PDO::FETCH_ASSOC) as $location) { $locationConfig = wayfarerRuntimeDecodeConfig($location['config'] ?? null); if (!isset($locationConfig['position']) || !is_numeric($locationConfig['position'])) { continue; } $locationPosition = (float)$locationConfig['position']; $ahead = $direction < 0 ? $locationPosition < $current : $locationPosition > $current; if (!$ahead) { continue; } $location['_runtime_config'] = $locationConfig; if (!wayfarerRuntimeLocationConditionsPass($pdo, $gameId, $location, $runtimeState)) { continue; } $distance = abs($locationPosition - $current); if ($best === null || $distance < $bestDistance) { $best = $location; $bestDistance = $distance; } } if ($best !== null) { $distance = wayfarerNormalizeRuntimeNumber((float)$bestDistance); $name = trim((string)($best['name'] ?? 'Location')) ?: 'Location'; $result['next_location_id'] = (int)($best['id'] ?? 0); $result['next_location_name'] = $name; $result['distance_to_next'] = $distance; $result['next_label'] = $name . ' — ' . $distance . ' ' . $result['distance_label']; } return $result; } function wayfarerRuntimeActiveTraitDefinitions( array $config ): array { $traits = $config['traits'] ?? []; if (!is_array($traits)) { return []; } $result = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( array_key_exists( 'active', $trait ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $trait['_runtime_id'] = $traitId; $result[] = $trait; } return $result; } function wayfarerRuntimeInitialTraitValue( array $trait ) { if ( array_key_exists( 'initial', $trait ) ) { return $trait['initial']; } $type = strtolower( trim( (string)( $trait['type'] ?? '' ) ) ); if ( $type === 'number' || $type === 'binary' || $type === 'boolean' ) { return 0; } return null; } function wayfarerRuntimeInitialTraitState( array $config ): array { $state = []; foreach ( wayfarerRuntimeActiveTraitDefinitions( $config ) as $trait ) { $traitId = (string)$trait['_runtime_id']; $state[$traitId] = wayfarerRuntimeInitialTraitValue( $trait ); } return $state; } /* |-------------------------------------------------------------------------- | Persisted state for every Region |-------------------------------------------------------------------------- | WAYFARER_ALL_REGION_STATE_V46 | | Game-wide Conditions may inspect a Region that the player has never visited. | Therefore every authored Region needs persistent Trait state, not only the | current Region. | | This helper is additive/recovery-safe: | - existing runtime values are never overwritten; | - missing Regions or newly-added Traits receive their authored initial; | - stale historical values are not destructively removed here. | */ function wayfarerRuntimeEnsureAllRegionTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['regions']) || !is_array($runtimeState['regions']) ) { $runtimeState['regions'] = []; } $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? ORDER BY id " ); $stmt->execute([ $gameId ]); $regions = $stmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($regions as $region) { $regionId = (int)( $region['id'] ?? 0 ); if ($regionId <= 0) { continue; } $regionKey = (string)$regionId; $config = wayfarerRuntimeDecodeConfig( $region['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset( $runtimeState['regions'][$regionKey] ) || !is_array( $runtimeState['regions'][$regionKey] ) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset( $runtimeState['regions'][$regionKey]['traits'] ) || !is_array( $runtimeState['regions'][$regionKey]['traits'] ) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['regions'][$regionKey]['traits'] ) ) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $initialValue; } } } /* |-------------------------------------------------------------------------- | Persisted state for every Location | WAYFARER_ALL_LOCATION_STATE_V56 |-------------------------------------------------------------------------- | | Locations can own Traits, so universal Conditions/Effects need persistent | per-playthrough Location Trait state just like Regions. | */ function wayfarerRuntimeEnsureAllLocationTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['locations']) || !is_array($runtimeState['locations']) ) { $runtimeState['locations'] = []; } $stmt = $pdo->prepare( " SELECT l.id, l.region_id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $location ) { $locationId = (int)($location['id'] ?? 0); if ($locationId <= 0) { continue; } $locationKey = (string)$locationId; $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset($runtimeState['locations'][$locationKey]) || !is_array($runtimeState['locations'][$locationKey]) ) { $runtimeState['locations'][$locationKey] = []; } /* * WAYFARER_LIVE_LOCATION_ANCHORS_V76 * * These are definition-derived spatial facts, so refresh them every * ensure pass. Moving a Location in Builder therefore changes the * anchor without rewriting authored Conditions. */ $runtimeState['locations'][$locationKey]['region_id'] = (int)( $location['region_id'] ?? 0 ); $runtimeState['locations'][$locationKey]['position'] = isset($config['position']) && is_numeric($config['position']) ? (float)$config['position'] : null; if ( !isset($runtimeState['locations'][$locationKey]['traits']) || !is_array($runtimeState['locations'][$locationKey]['traits']) ) { $runtimeState['locations'][$locationKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['locations'][$locationKey]['traits'] ) ) { continue; } $runtimeState['locations'][$locationKey]['traits'][$traitId] = $initialValue; } } } function wayfarerRuntimeLoadStartingRegion( PDO $pdo, int $gameId, ?array $gameConfig = null ): ?array { if ($gameId <= 0) { return null; } if ($gameConfig === null) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { return null; } $gameConfig = wayfarerRuntimeDecodeConfig( $rawConfig ); } $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT * FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $startingRegionId, $gameId, ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); return $region ?: null; } function wayfarerRuntimeInitializeGame( PDO $pdo, int $gameUserId, array $setupInput = [] ): array { if ($gameUserId <= 0) { throw new RuntimeException( 'A valid game-user ID is required to initialize the game.' ); } try { $pdo->beginTransaction(); /* * Lock the playthrough row so two initialize requests cannot create * competing starting states. */ $stmt = $pdo->prepare( " SELECT gu.id, gu.game_id, gu.user_id, gu.role, gu.state, g.config AS game_config, g.player_slots AS player_slots FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id WHERE gu.id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } /* * Initialization is intentionally one-way. To restart a development * game, clear wf_game_users.state to NULL first. */ if ( $row['state'] !== null && $row['state'] !== '' ) { throw new RuntimeException( 'This game-user already has runtime state.' ); } $gameId = (int)$row['game_id']; $gameConfig = wayfarerRuntimeDecodeConfig( $row['game_config'] ?? null ); $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { throw new RuntimeException( 'This Game does not have a Starting Region configured.' ); } $startingRegion = wayfarerRuntimeLoadStartingRegion( $pdo, $gameId, $gameConfig ); if (!$startingRegion) { throw new RuntimeException( 'The configured Starting Region does not exist for this Game.' ); } $regionConfig = wayfarerRuntimeDecodeConfig( $startingRegion['config'] ?? null ); $gameTraits = wayfarerRuntimeInitialTraitState( $gameConfig ); $regionTraits = wayfarerRuntimeInitialTraitState( $regionConfig ); $navigation = [ 'region_id' => (int)$startingRegion['id'], /* WAYFARER_BIDIRECTIONAL_TRAVEL_V71 */ 'direction' => 1, ]; /* * Linear travel currently begins at coordinate zero. * Other navigation modes will get their own authored start-state * rules when those modes are implemented. */ if ( strtolower( trim( (string)( $startingRegion['navigation_type'] ?? '' ) ) ) === 'linear' ) { $navigation['position'] = 0; } $regionKey = (string)$startingRegion['id']; $state = [ /* * WAYFARER_RUNTIME_SCHEMA_V55 * WAYFARER_BIDIRECTIONAL_TRAVEL_V71 * Version the persisted playthrough contract explicitly. */ 'schema_version' => 2, 'game' => [ 'traits' => $gameTraits, ], 'navigation' => $navigation, 'regions' => [ $regionKey => [ 'traits' => $regionTraits, ], ], 'runtime' => [ 'tick' => 0, ], ]; /* WAYFARER_INITIALIZE_GAME_CLOCK_V61 */ wayfarerRuntimeInitializeClockFromConfig( $gameConfig, $state ); /* * WAYFARER_ALL_REGION_STATE_V46 * * Global conditions can inspect Region state before that Region has * ever been visited. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $state ); /* WAYFARER_INITIALIZE_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_V49 * * Items and Characters receive canonical per-playthrough state before * any starting setup effects are applied. */ wayfarerRuntimeEnsureEntityState( $pdo, $gameId, $state ); /* * WAYFARER_APPLY_STARTING_CHOICES_V36 * * Validate all player-entered setup data before persisting anything. * Then apply the selected options' starting effects to the canonical * initial runtime state. */ $preparedSetup = wayfarerRuntimePrepareStartingSetup( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $setupInput ); /* * WAYFARER_PLAYER_TRAITS_RUNTIME_V52 * * Persist the validated player-character names before creating the * per-player Trait state. Each slot receives independent values. */ $state['setup'] = [ 'player_names' => $preparedSetup['player_names'], 'choices' => $preparedSetup['choices'], ]; wayfarerRuntimeEnsureGameTraitState( $gameConfig, $state ); wayfarerRuntimeEnsurePlayerState( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $state ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $startingEffectReports = []; foreach ( $preparedSetup['effects'] as $startingEffect ) { /* WAYFARER_STARTING_CHOICE_RANDOM_RUNTIME_V58 */ $resolvedStartingValue = wayfarerRuntimeRollEffectValue( $startingEffect ); if (!$resolvedStartingValue['valid']) { throw new RuntimeException( 'Unable to resolve a Starting Choice effect value: ' . $resolvedStartingValue['error'] ); } $concreteStartingEffect = $startingEffect; $concreteStartingEffect['value'] = $resolvedStartingValue['value']; $report = wayfarerApplyRuntimeEffect( $state, $concreteStartingEffect, (int)$startingRegion['id'], $traitDefinitions ); if ( !empty($report['error']) ) { throw new RuntimeException( 'Unable to apply a Starting Choice effect: ' . $report['error'] ); } $report['value_mode'] = $resolvedStartingValue['mode']; $report['rolled_value'] = $resolvedStartingValue['value']; if ($resolvedStartingValue['mode'] === 'random') { $report['random_min'] = $resolvedStartingValue['min']; $report['random_max'] = $resolvedStartingValue['max']; } $startingEffectReports[] = $report; } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * * Starting-effect reports are intentionally not persisted. The effects * themselves have already mutated canonical state. */ /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 * * A game that begins exactly on an active Location should present that * Location before ordinary Action Mode. */ $startingLocation = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $state ); if ($startingLocation) { /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Starting on a Location is a real Location trigger. Apply its * one-time Effects before opening Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, $gameId, $startingLocation, $state ); $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$startingLocation['id'], 'phase' => 'presentation', 'trigger' => 'initial', 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /* WAYFARER_GAME_ENDINGS_INITIAL_V45 */ wayfarerRuntimeEvaluateGameEndings( $pdo, $gameId, $state ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => $gameId, 'starting_region_id' => (int)$startingRegion['id'], 'state' => $state, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Resolve a game-user record for the tick prototype. * * Preferred: * explicit wf_game_users.id * * Temporary development fallback: * first wf_game_users record. * * The fallback exists only because Wayfarer has not yet wired the play * page to authentication / game selection. Once that exists, remove the * fallback and always pass the active game_user_id explicitly. */ function wayfarerRuntimeResolveGameUserId( PDO $pdo, ?int $requestedId = null ): int { if ($requestedId !== null && $requestedId > 0) { $stmt = $pdo->prepare( " SELECT id FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $requestedId ]); $id = $stmt->fetchColumn(); if ($id !== false) { return (int)$id; } throw new RuntimeException( 'The requested game-user record does not exist.' ); } $id = $pdo ->query( " SELECT id FROM wf_game_users ORDER BY id LIMIT 1 " ) ->fetchColumn(); if ($id === false) { throw new RuntimeException( 'No wf_game_users record exists.' ); } return (int)$id; } /** * Load one game-user runtime state. */ function wayfarerRuntimeLoad( PDO $pdo, int $gameUserId ): array { $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'state' => $state, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /** * Advance exactly one tick. * * This is intentionally the entire v14 game engine: * * state.runtime.tick += 1 * * The transaction and row lock are already in place so later work can add * movement/effects without changing the request architecture. */ /* |-------------------------------------------------------------------------- | Linear Location crossing | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | A tick may move farther than the distance to the next authored Location. | This resolver finds the FIRST active Location crossed in the direction of | travel. The movement is then clamped to that exact authored coordinate. | | Examples: | 45 -> 55, Location 50 => stop at 50 | 55 -> 45, Location 50 => stop at 50 | | The starting coordinate is intentionally exclusive so leaving a Location | does not immediately trigger that same Location again. | */ /* |-------------------------------------------------------------------------- | Location Conditions | WAYFARER_LOCATION_CONDITIONS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeLocationConditionsPass( PDO $pdo, int $gameId, array $location, array &$runtimeState ): bool { $config = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $conditions = $config['conditions'] ?? null; $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; return wayfarerConditionsPass( $conditions, $resolver ); } /* |-------------------------------------------------------------------------- | Game Endings | WAYFARER_GAME_ENDINGS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeEvaluateGameEndings( PDO $pdo, int $gameId, array &$runtimeState ): ?array { /* * WAYFARER_ALL_REGION_STATE_ENDINGS_V46 */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $runtimeState ); /* WAYFARER_RECOVER_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $runtimeState ); if ( isset( $runtimeState['runtime']['ending'] ) && is_array( $runtimeState['runtime']['ending'] ) ) { return $runtimeState['runtime']['ending']; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); $config = wayfarerRuntimeDecodeConfig( $raw === false ? null : $raw ); $endings = $config['endings'] ?? []; if (!is_array($endings)) { return null; } $ordered = []; foreach ( array_values($endings) as $index => $ending ) { if (!is_array($ending)) { continue; } $ending['_runtime_order'] = $index; $ordered[] = $ending; } usort( $ordered, function ( array $a, array $b ): int { $priorityCompare = (int)( $b['priority'] ?? 0 ) <=> (int)( $a['priority'] ?? 0 ); if ($priorityCompare !== 0) { return $priorityCompare; } return (int)( $a['_runtime_order'] ?? 0 ) <=> (int)( $b['_runtime_order'] ?? 0 ); } ); $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; foreach ($ordered as $ending) { if ( !wayfarerConditionsPass( $ending['conditions'] ?? null, $resolver ) ) { continue; } $terminal = [ 'id' => (string)( $ending['id'] ?? '' ), 'title' => (string)( $ending['title'] ?? 'The End' ), 'text' => (string)( $ending['text'] ?? '' ), 'image_media_id' => (int)( $ending['image_media_id'] ?? 0 ), 'priority' => (int)( $ending['priority'] ?? 0 ), 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), ]; if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['ending'] = $terminal; unset( $runtimeState['runtime']['presentation'] ); return $terminal; } return null; } function wayfarerRuntimeFindCrossedLocation( PDO $pdo, int $gameId, int $regionId, $fromPosition, $toPosition, array &$runtimeState ): ?array { /* * WAYFARER_CROSSED_LOCATION_STATE_FIX_V46_1 * * Location Conditions are evaluated against the live runtime state. */ if ( $gameId <= 0 || $regionId <= 0 || !is_numeric($fromPosition) || !is_numeric($toPosition) ) { return null; } $from = (float)$fromPosition; $to = (float)$toPosition; if ($from === $to) { return null; } /* * Crossing interception currently applies only to Linear Regions. */ $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); $forward = $to > $from; $candidate = null; $candidatePosition = null; foreach ($locations as $location) { $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $locationPosition = (float)$config['position']; /* * The starting point is exclusive; the destination is inclusive. */ $crossed = $forward ? ( $locationPosition > $from && $locationPosition <= $to ) : ( $locationPosition < $from && $locationPosition >= $to ); if (!$crossed) { continue; } $location['_runtime_config'] = $config; if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } if ($candidate === null) { $candidate = $location; $candidatePosition = $locationPosition; } else { $nearer = $forward ? ( $locationPosition < $candidatePosition ) : ( $locationPosition > $candidatePosition ); $samePositionLowerId = $locationPosition === $candidatePosition && (int)$location['id'] < (int)$candidate['id']; if ( $nearer || $samePositionLowerId ) { $candidate = $location; $candidatePosition = $locationPosition; } } } if ($candidate === null) { return null; } $candidate['_runtime_position'] = wayfarerNormalizeRuntimeNumber( (float)$candidatePosition ); $candidate['_runtime_config'] = wayfarerRuntimeDecodeConfig( $candidate['config'] ?? null ); return $candidate; } /* |-------------------------------------------------------------------------- | Player spatial context |-------------------------------------------------------------------------- | WAYFARER_PLAYER_CONTEXT_V46_1 | | This is the canonical player-facing spatial label for Action Mode: | | exact eligible Location -> Location name + Region parent context | otherwise -> Region name | | It is returned by runtime APIs so the browser does not keep displaying the | Location name from the page's original server render after movement. | */ function wayfarerRuntimePlayerContext( PDO $pdo, int $gameId, array $runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionName = ''; if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $rawRegionName = $stmt->fetchColumn(); if ($rawRegionName !== false) { $regionName = trim( (string)$rawRegionName ); } } $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); $locationId = 0; $locationName = ''; if ($location) { $locationId = (int)( $location['id'] ?? 0 ); $locationName = trim( (string)( $location['name'] ?? '' ) ); } $contextName = $locationName !== '' ? $locationName : ( $regionName !== '' ? $regionName : 'Actions' ); return [ 'region_id' => $regionId, 'region_name' => $regionName, 'location_id' => $locationId, 'location_name' => $locationName, 'context_name' => $contextName, 'parent_context' => $locationName !== '' ? $regionName : '', ]; } /* |-------------------------------------------------------------------------- | Presentation dismissal |-------------------------------------------------------------------------- | | Presentation is persisted runtime state. This function is intentionally | generic even though v40 renders only Location presentations; Storylets can | use the same mechanism later. | */ function wayfarerRuntimeDismissPresentation( PDO $pdo, int $gameUserId, string $expectedType = '', int $expectedId = 0 ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); $presentation = $state['runtime']['presentation'] ?? null; if ( !is_array($presentation) || empty($presentation['type']) || empty($presentation['id']) ) { throw new RuntimeException( 'There is no active presentation to continue.' ); } if ( $expectedType !== '' && (string)$presentation['type'] !== $expectedType ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } if ( $expectedId > 0 && (int)$presentation['id'] !== $expectedId ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } /* WAYFARER_STORYLET_BRANCH_DISMISS_GUARD_V59 */ if ( (string)($presentation['type'] ?? '') === 'storylet' ) { $eligibleBranches = wayfarerRuntimeEligibleStoryletBranches( $pdo, (int)$row['game_id'], (int)($presentation['id'] ?? 0), $state ); if ($eligibleBranches) { throw new RuntimeException( 'Choose an available Storylet Branch before continuing.' ); } } $state['runtime']['last_presentation'] = $presentation; unset( $state['runtime']['presentation'] ); /* WAYFARER_STATUS_STORYLET_QUEUE_V88 */ wayfarerRuntimePromotePendingStatusStorylet( $pdo, (int)$row['game_id'], $state ); wayfarerRuntimeEvaluateGameEndings( $pdo, (int)$row['game_id'], $state ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'dismissed' => $presentation, /* WAYFARER_STORYLET_ACTIONS_AFTER_PRESENTATION_V59 */ 'actions' => isset($state['runtime']['presentation']) && is_array($state['runtime']['presentation']) ? [] : wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$row['game_id'], $state ), /* WAYFARER_STATUS_STORYLET_QUEUE_V88 */ 'presentation' => isset($state['runtime']['presentation']) && is_array($state['runtime']['presentation']) ? $state['runtime']['presentation'] : null, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Advance exactly one tick. * * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ function wayfarerRuntimeAdvanceTick( PDO $pdo, int $gameUserId, ?int $requestedTravelDirection = null ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } /* WAYFARER_BIDIRECTIONAL_TRAVEL_V71 */ if ( !isset($state['navigation']) || !is_array($state['navigation']) ) { $state['navigation'] = []; } if ( $requestedTravelDirection !== null && !in_array( $requestedTravelDirection, [-1, 1], true ) ) { throw new RuntimeException( 'Travel direction must be -1 or 1.' ); } $state['navigation']['direction'] = $requestedTravelDirection !== null ? $requestedTravelDirection : wayfarerRuntimeNavigationDirection( $state ); $state['schema_version'] = max( 2, (int)( $state['schema_version'] ?? 1 ) ); /* * WAYFARER_GAME_ENDINGS_TERMINAL_V45 * * Endings are terminal runtime state. */ if ( isset( $state['runtime']['ending'] ) && is_array( $state['runtime']['ending'] ) ) { throw new RuntimeException( 'This game has ended.' ); } /* * Narrative presentation is a true runtime pause, not merely a hidden * browser control. Even Author Mode cannot advance ticks through it. */ if ( isset( $state['runtime']['presentation'] ) && is_array( $state['runtime']['presentation'] ) ) { throw new RuntimeException( 'Continue the current presentation before advancing time.' ); } /* * WAYFARER_ALL_REGION_STATE_TICK_V46 * * Recovery path for playthroughs initialized before v46, and for * Regions/Traits authored after a playthrough began. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_LOCATION_STATE_TICK_RECOVERY_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, (int)$row['game_id'], $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_TICK_V49 * * Recovery path for playthroughs created before V49 and for Entities * or Traits authored after play began. */ wayfarerRuntimeEnsureEntityState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_PLAYER_TRAITS_TICK_RECOVERY_V52 */ wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$row['game_id'], $state ); $regionIdBeforeTick = (int)( $state['navigation']['region_id'] ?? 0 ); $positionBeforeTick = $state['navigation']['position'] ?? null; $currentTick = (int)( $state['runtime']['tick'] ?? 0 ); $state['runtime']['tick'] = $currentTick + 1; /* WAYFARER_ADVANCE_GAME_CLOCK_V61 */ wayfarerRuntimeAdvanceClock( $pdo, (int)$row['game_id'], $state ); /* * Global Game Tick Effects run first. */ wayfarerApplyGameTickEffects( $pdo, (int)$row['game_id'], $state ); /* * Current Region Tick Effects then perform Region-level movement and * state changes. */ wayfarerApplyCurrentRegionTickEffects( $pdo, $state ); /* * Resolve whether the completed movement would have crossed an active * Location. If so, stop at the nearest crossed Location rather than * accepting the overshot position. */ $regionIdAfterMovement = (int)( $state['navigation']['region_id'] ?? 0 ); $positionAfterMovement = $state['navigation']['position'] ?? null; $crossedLocation = null; /* * We only interpret movement as a continuous path while the player * remains in the same Region. Region transitions will later get their * own explicit navigation semantics. */ if ( $regionIdBeforeTick > 0 && $regionIdBeforeTick === $regionIdAfterMovement ) { $crossedLocation = wayfarerRuntimeFindCrossedLocation( $pdo, (int)$row['game_id'], $regionIdAfterMovement, $positionBeforeTick, $positionAfterMovement, $state ); } $arrivalPosition = null; if ($crossedLocation) { $arrivalPosition = $crossedLocation[ '_runtime_position' ]; $state['navigation']['position'] = $arrivalPosition; /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Event Effects execute once at the trigger boundary. They run * before the Location's Tick Effects and before Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, (int)$row['game_id'], $crossedLocation, $state ); } /* * WAYFARER_LOCATION_EFFECTS_ONLY_RUNTIME_V43 * * Locations no longer own Tick Effects. Game and Region Tick Effects * have already advanced the simulation; a crossed Location contributes * its one-time Effects and then interrupts with Presentation Mode. */ if ($crossedLocation) { $state['navigation']['position'] = $arrivalPosition; $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$crossedLocation['id'], 'phase' => 'presentation', 'trigger' => 'arrival', 'tick' => (int)$state['runtime']['tick'], ]; $state['runtime']['last_location_arrival'] = [ 'location_id' => (int)$crossedLocation['id'], 'region_id' => $regionIdAfterMovement, 'position' => $arrivalPosition, 'tick' => (int)$state['runtime']['tick'], ]; } /* WAYFARER_STATUS_LIFECYCLE_TICK_V88 */ wayfarerRuntimeProcessStatusProgression( $pdo, (int)$row['game_id'], $state ); /* * A Location presentation keeps priority for the completed travel * tick. Status Storylets queue behind it and are promoted as soon as * there is no active presentation. */ wayfarerRuntimePromotePendingStatusStorylet( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_GAME_ENDINGS_AFTER_TICK_V45 */ $ending = wayfarerRuntimeEvaluateGameEndings( $pdo, (int)$row['game_id'], $state ); $state['runtime']['last_tick_at'] = gmdate('c'); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId ]); $pdo->commit(); $presentation = $state['runtime']['presentation'] ?? null; $pausedForPresentation = is_array($presentation) && !empty($presentation['type']) && !empty($presentation['id']); $ending = $state['runtime']['ending'] ?? null; $ended = is_array($ending) && !empty($ending['id']); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'tick' => (int)$state['runtime']['tick'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'pause' => $pausedForPresentation || $ended, 'pause_reason' => $ended ? 'Game ended.' : ( $pausedForPresentation ? ( (string)($presentation['type'] ?? '') === 'storylet' ? 'Storylet opened.' : 'Location reached.' ) : '' ), 'presentation' => $pausedForPresentation ? $presentation : null, 'ending' => $ended ? $ending : null, /* WAYFARER_STORYLET_ACTIONS_TICK_API_V59 */ 'actions' => !$pausedForPresentation && !$ended ? wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$row['game_id'], $state ) : [], /* WAYFARER_TRAVEL_TIME_HUD_V61 */ 'travel' => wayfarerRuntimeBuildTravelStatus( $pdo, (int)$row['game_id'], $state ), ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: _wayfarer_backups/v94-20260930-170953/admin/games/edit.php ================================================================================ null, 'name' => '', 'slug' => '', 'description' => '', 'instructions' => '', 'player_slots' => 0, 'config' => null, 'status' => 'draft' ]; $traits = []; $playerTraits = []; $config = []; /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ $gameTickEffects = []; $gameplayTickEffects = []; $startingChoices = []; $startingChoicesEditor = []; $startingChoiceItemTargets = []; /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 */ $gameEndings = []; $gameEndingsEditor = []; /* WAYFARER_GAME_TRAVEL_MODIFIERS_V73 */ $gameTravelModifiers = []; $gameTravelModifiersEditor = []; $error = ''; // ================================================================ // LOAD EXISTING GAME // ================================================================ if (isset($_GET['id'])) { $stmt = $pdo->prepare(" SELECT * FROM wf_games WHERE id = ? "); $stmt->execute([(int)$_GET['id']]); $existingGame = $stmt->fetch(); if (!$existingGame) { die('Game not found.'); } $game = array_merge($game, $existingGame); if (!empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } if ( isset($config['traits']) && is_array($config['traits']) ) { $traits = $config['traits']; } /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ if ( isset($config['player_traits']) && is_array($config['player_traits']) ) { $playerTraits = array_values( $config['player_traits'] ); } /* WAYFARER_LOAD_GAME_TICK_EFFECTS_V33 */ if ( isset($config['tick_effects']) && is_array($config['tick_effects']) ) { $gameTickEffects = array_values( $config['tick_effects'] ); } if ( isset($config['setup']['choices']) && is_array($config['setup']['choices']) ) { $startingChoices = array_values( $config['setup']['choices'] ); } /* WAYFARER_GAME_ENDINGS_V45 */ if ( isset($config['endings']) && is_array($config['endings']) ) { $gameEndings = array_values( $config['endings'] ); } /* WAYFARER_LOAD_GAME_TRAVEL_MODIFIERS_V73 */ if ( isset($config['travel']['modifiers']) && is_array($config['travel']['modifiers']) ) { $gameTravelModifiers = array_values( $config['travel']['modifiers'] ); } } $gameplayTickEffects = $gameTickEffects; $startingChoicesEditor = $startingChoices; $gameEndingsEditor = $gameEndings; $gameTravelModifiersEditor = $gameTravelModifiers; // ================================================================ // SAVE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $game['id'] = !empty($_POST['id']) ? (int)$_POST['id'] : null; $game['name'] = trim($_POST['name'] ?? ''); $game['slug'] = trim($_POST['slug'] ?? ''); $game['description'] = trim($_POST['description'] ?? ''); $game['instructions'] = trim($_POST['instructions'] ?? ''); $game['player_slots'] = max(0, (int)($_POST['player_slots'] ?? 0)); $game['status'] = $_POST['status'] ?? 'draft'; // ------------------------------------------------------------ // BUILD GAME + PLAYER TRAIT DEFINITIONS // WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 // ------------------------------------------------------------ $traitResult = buildTraitsFromPost( $_POST['traits'] ?? [], ['status'] ); $traits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } $playerTraitResult = buildTraitsFromPost( $_POST['player_traits'] ?? [], ['status'] ); $playerTraits = $playerTraitResult['traits']; if ( $error === '' && $playerTraitResult['error'] !== '' ) { $error = 'Player Traits: ' . $playerTraitResult['error']; } /* WAYFARER_GAME_SETTINGS_UNIVERSAL_TARGETS_V56 */ $gameplayRuntimeTargets = gameplayRuntimeStateTargets( $pdo, (int)($game['id'] ?? 0), $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ) ); /* WAYFARER_BUILD_GAME_TRAVEL_MODIFIERS_V73 */ $postedGameTravelModifiers = isset($_POST['travel_modifiers']) && is_array($_POST['travel_modifiers']) ? array_values( $_POST['travel_modifiers'] ) : []; $gameTravelModifiersEditor = $postedGameTravelModifiers; /* * Build the editor structure even if another Game Settings section already * has a validation error. This keeps selected Travel Conditions intact on the * failed POST. Only the first validation error becomes the page error. */ $gameTravelModifierResult = wayfarerBuildGameTravelModifiersFromPost( $postedGameTravelModifiers, $gameplayRuntimeTargets ); $gameTravelModifiersEditor = $gameTravelModifierResult[ 'editor_modifiers' ]; if ( $error === '' && $gameTravelModifierResult['error'] !== '' ) { $error = $gameTravelModifierResult['error']; } if ( $gameTravelModifierResult['error'] === '' ) { $gameTravelModifiers = $gameTravelModifierResult[ 'modifiers' ]; } /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 */ $startingChoiceItemTargets = wayfarerStartingChoiceItemQuantityTargets( $pdo, (int)($game['id'] ?? 0) ); /* WAYFARER_BUILD_STARTING_CHOICES_V36 */ /* WAYFARER_STARTING_CHOICES_CANONICAL_EMPTY_V36_1 */ $startingChoicesSubmitted = isset($_POST['setup_choices_present']) || array_key_exists( 'setup_choices', $_POST ); $postedStartingChoices = isset($_POST['setup_choices']) && is_array($_POST['setup_choices']) ? array_values( $_POST['setup_choices'] ) : []; $startingChoicesEditor = $postedStartingChoices; if ($error === '') { $startingChoiceResult = wayfarerBuildStartingChoicesFromPost( $postedStartingChoices, $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ), $startingChoiceItemTargets, $gameplayRuntimeTargets ); if ( $startingChoiceResult['error'] !== '' ) { $error = $startingChoiceResult['error']; } else { $startingChoices = $startingChoiceResult['choices']; $startingChoicesEditor = $startingChoices; } } /* WAYFARER_BUILD_GAME_ENDINGS_V45 */ $postedGameEndings = isset($_POST['endings']) && is_array($_POST['endings']) ? array_values( $_POST['endings'] ) : []; $gameEndingsEditor = $postedGameEndings; if ($error === '') { $endingResult = wayfarerBuildGameEndingsFromPost( $pdo, (int)($game['id'] ?? 0), $postedGameEndings, $traits ); if ( $endingResult['error'] !== '' ) { $error = $endingResult['error']; } else { $gameEndings = $endingResult['endings']; $gameEndingsEditor = $gameEndings; } } /* WAYFARER_BUILD_GAME_TICK_EFFECTS_V33 */ $postedGameTickEffects = isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ? array_values( $_POST['tick_effects'] ) : []; $gameplayTickEffects = $postedGameTickEffects; if ($error === '') { $gameTickEffectResult = gameplayBuildTickEffectsFromPost( $gameplayRuntimeTargets, $postedGameTickEffects, $gameplayRuntimeTargets ); if ( $gameTickEffectResult['error'] !== '' ) { $error = $gameTickEffectResult['error']; } else { $gameTickEffects = $gameTickEffectResult['effects']; $gameplayTickEffects = $gameTickEffects; } } // ------------------------------------------------------------ // VALIDATE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['name'] === '') { $error = 'Game name is required.'; } elseif ($game['slug'] === '') { $error = 'Slug is required.'; } } // ------------------------------------------------------------ // BUILD CONFIG JSON // ------------------------------------------------------------ if ($error === '') { /* * Preserve any other configuration already stored for the game. * Traits own only the "traits" section of config. */ if (empty($config) && !empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } $startingRegionId = !empty($_POST['starting_region_id']) ? (int)$_POST['starting_region_id'] : null; if ($startingRegionId !== null) { $config['starting_region_id'] = $startingRegionId; } else { unset($config['starting_region_id']); } /* WAYFARER_GAME_TIME_SETTINGS_SAVE_V61 */ $timeMode = strtolower(trim((string)($_POST['time_tracking'] ?? 'none'))); if (!in_array($timeMode, ['none', 'days', 'hours'], true)) { $timeMode = 'none'; } if ($timeMode === 'none') { unset($config['time']); } else { $startingDay = max(1, (int)($_POST['time_starting_day'] ?? 1)); if ($timeMode === 'days') { $config['time'] = [ 'mode' => 'days', 'starting_day' => $startingDay, 'ticks_per_day' => max(1, (int)($_POST['time_ticks_per_day'] ?? 1)), ]; } else { $hoursPerDay = max(1, (int)($_POST['time_hours_per_day'] ?? 24)); $startingHour = max(0, (int)($_POST['time_starting_hour'] ?? 8)); if ($startingHour >= $hoursPerDay) { $startingHour = $hoursPerDay - 1; } $config['time'] = [ 'mode' => 'hours', 'starting_day' => $startingDay, 'starting_hour' => $startingHour, 'ticks_per_hour' => max(1, (int)($_POST['time_ticks_per_hour'] ?? 1)), 'hours_per_day' => $hoursPerDay, ]; } } $config['traits'] = array_values( $traits ); /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ $config['player_traits'] = array_values( $playerTraits ); /* WAYFARER_SAVE_GAME_TRAVEL_MODIFIERS_V73 */ if ($gameTravelModifiers) { if ( !isset($config['travel']) || !is_array($config['travel']) ) { $config['travel'] = []; } $config['travel']['modifiers'] = array_values( $gameTravelModifiers ); } else { if ( isset($config['travel']) && is_array($config['travel']) ) { unset( $config['travel']['modifiers'] ); if ($config['travel'] === []) { unset($config['travel']); } } } /* WAYFARER_SAVE_GAME_TICK_EFFECTS_V33 */ if ($gameTickEffects) { $config['tick_effects'] = array_values( $gameTickEffects ); } else { unset( $config['tick_effects'] ); } /* WAYFARER_SAVE_STARTING_CHOICES_V36 */ /* * WAYFARER_STARTING_CHOICES_CANONICAL_EMPTY_V36_1 * * If the Setup editor participated in this submission, its current * collection is the canonical collection. In particular, deleting the * final card must persist an explicit empty array rather than relying * on the absence of setup_choices in POST. */ if ($startingChoicesSubmitted) { if ( !isset($config['setup']) || !is_array($config['setup']) ) { $config['setup'] = []; } $config['setup']['choices'] = array_values( $startingChoices ); } /* WAYFARER_SAVE_GAME_ENDINGS_V45 */ $config['endings'] = array_values( $gameEndings ); $game['config'] = json_encode( $config, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ($game['config'] === false) { $error = 'Unable to encode game configuration.'; } } // ------------------------------------------------------------ // WRITE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['id']) { $stmt = $pdo->prepare(" UPDATE wf_games SET name = ?, slug = ?, description = ?, instructions = ?, player_slots = ?, config = ?, status = ? WHERE id = ? "); $stmt->execute([ $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'], $game['id'] ]); } else { $uuid = bin2hex(random_bytes(16)); $stmt = $pdo->prepare(" INSERT INTO wf_games ( uuid, name, slug, description, instructions, player_slots, config, status ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $uuid, $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'] ]); $game['id'] = (int)$pdo->lastInsertId(); } /* * WAYFARER_STAY_ON_GAME_SETTINGS_V38 * * Successful saves remain on the same Game Settings record and reopen * the tab the author was using. */ $savedGameTab = (string)( $_POST['active_tab'] ?? 'basics' ); if ( !in_array( $savedGameTab, [ 'basics', 'setup', 'traits', 'gameplay', 'travel', 'endings', ], true ) ) { $savedGameTab = 'basics'; } header( 'Location: edit.php?id=' . (int)$game['id'] . '&tab=' . rawurlencode( $savedGameTab ) ); exit; } } // ================================================================ // LOAD REGIONS FOR STARTING REGION // ================================================================ $regions = []; if (!empty($game['id'])) { $regionStmt = $pdo->prepare(" SELECT id, name FROM wf_regions WHERE game_id = ? ORDER BY name "); $regionStmt->execute([ $game['id'] ]); $regions = $regionStmt->fetchAll(); } /* WAYFARER_GAME_SETTINGS_UNIVERSAL_TARGETS_RENDER_V56 */ $gameplayRuntimeTargets = gameplayRuntimeStateTargets( $pdo, (int)($game['id'] ?? 0), $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ) ); /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_RENDER_V54 */ $startingChoiceItemTargets = wayfarerStartingChoiceItemQuantityTargets( $pdo, (int)($game['id'] ?? 0) ); ?> <?= $game['id'] ? 'Edit Game' : 'New Game' ?> - Wayfarer


Example: stormgate

================================================================================ FILE: _wayfarer_backups/v94-20260930-170953/includes/builder/editor.php ================================================================================ $groupValues) { if (!is_array($groupValues)) { continue; } foreach ($groupValues as $traitName => $traitValue) { if (!isset($storedSystemTraitValues[$groupName][$traitName])) { $storedSystemTraitValues[$groupName][$traitName] = $traitValue; } } } } } $renderSystemTraits = function () use ( $record, $fields, $systemTraitsSelector, $systemTraitGroups, $storedSystemTraitValues, $storedSystemTraitMaxValues, $storedSystemTraitInitialModes, $storedSystemTraitInitialMins, $storedSystemTraitInitialMaxs ) { if (!$systemTraitsSelector || empty($systemTraitGroups)) { return; } $selectedGroup = $record[$systemTraitsSelector] ?? ($fields[$systemTraitsSelector]['default'] ?? ''); ?>
$groupDefinition): ?>
>

$traitDefinition): ?>

>

data-system-trait-fixed-initial >

>

Storylet duplicated. This copy is saved as Draft.

Copies the saved Storylet into a new Draft. If this is a Branch, the copy becomes a sibling Branch. Child Branches are not copied.
$relationship): ?> $field): ?>

Media

Optional. Reuse an existing asset from this game's Media Library, upload a new asset, or leave the slot empty.

$mediaDefinition): ?>

Maximum upload: .

Current: ()



No existing assets have been uploaded to this game yet.

$field): ?>






Traits

Traits are additional values associated with this that can change during play.


Danger Zone

Permanently delete this . This cannot be undone.

================================================================================ FILE: _wayfarer_backups/v94-20260930-170953/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects, array $storyletBindings = [], int $currentStoryletId = 0 ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $allowedTargets = array_replace( gameplayRuntimeStateTargets( $pdo, $gameId ), gameplayStoryletBindingEffectTargets( $pdo, $gameId, $storyletBindings ), gameplayStoryletNavigationEffectTargets( $pdo, $gameId, $currentStoryletId ) ); $result = gameplayBuildEffectsFromPost( $allowedTargets, $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); if (($result['error'] ?? '') !== '') { return $result; } $transitionCount = 0; foreach ( $result['effects'] ?? [] as $effect ) { if ( is_array($effect) && (string)( $effect['target_scope'] ?? '' ) === 'storylet' && (string)( $effect['target_type'] ?? '' ) === 'transition' ) { $transitionCount++; } } if ($transitionCount > 1) { return [ 'effects' => [], 'error' => 'A Storylet may have only one Storylet → Continue To Effect.', ]; } return $result; } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } /* WAYFARER_SYSTEM_TRAIT_RANDOM_INITIAL_SAVE_V93 */ function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /* * WAYFARER_LINEAR_TRAVEL_SAVE_V72 * * Dedicated Linear Travel is deliberately opt-in. A null travel result means * the Region should continue using the legacy Position Tick Effect path. */ function buildRegionTravelFromPost( array $record, array $postedTravel ): array { $navigationType = strtolower( trim( (string)( $record['navigation_type'] ?? '' ) ) ); if ($navigationType !== 'linear') { return [ 'travel' => null, 'error' => '', ]; } if (empty($postedTravel['enabled'])) { return [ 'travel' => null, 'error' => '', ]; } $baseDistance = $postedTravel['base_distance'] ?? ''; if ( !is_numeric($baseDistance) || (float)$baseDistance <= 0 ) { return [ 'travel' => null, 'error' => 'Base Distance Per Tick must be greater than zero.', ]; } $baseDistance = (float)$baseDistance; if (floor($baseDistance) === $baseDistance) { $baseDistance = (int)$baseDistance; } return [ 'travel' => [ 'enabled' => true, 'base_distance' => $baseDistance, 'allow_turnaround' => !empty( $postedTravel[ 'allow_turnaround' ] ), ], 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Storylet duplication | WAYFARER_STORYLET_DUPLICATE_V83 |-------------------------------------------------------------------------- | | Duplicate the currently saved Storylet as a Draft. The duplicate copies | the selected Storylet record and its media links, but intentionally does | not recursively copy child Branch Storylets. | */ function duplicateStoryletRecord( PDO $pdo, int $gameId, int $storyletId ): array { if ($gameId <= 0 || $storyletId <= 0) { return [ 'duplicated' => false, 'id' => 0, 'error' => 'A valid Storylet is required to duplicate.', ]; } try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, uuid, game_id, name, storylet_key, description, conditions, effects, max_occurrences, trigger_type, status, config, parent_storylet_id, choice_label, choice_sort_order, branch_label, branch_sort_order FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $storyletId, $gameId, ]); $source = $stmt->fetch(PDO::FETCH_ASSOC); if (!$source) { throw new RuntimeException( 'The Storylet to duplicate could not be found.' ); } $sourceName = trim( (string)( $source['name'] ?? 'Storylet' ) ); if ($sourceName === '') { $sourceName = 'Storylet'; } $baseName = preg_replace( '/\s+-\s+COPY(?:\s+\d+)?$/i', '', $sourceName ); if (!is_string($baseName) || trim($baseName) === '') { $baseName = $sourceName; } $baseName = trim($baseName); /* Leave room for " - COPY 9999" within the 200-character Name. */ $baseName = function_exists('mb_substr') ? mb_substr($baseName, 0, 185, 'UTF-8') : substr($baseName, 0, 185); $copyName = $baseName . ' - COPY'; $copyNumber = 2; $nameCheck = $pdo->prepare( " SELECT 1 FROM wf_storylets WHERE game_id = ? AND name = ? LIMIT 1 " ); while (true) { $nameCheck->execute([ $gameId, $copyName, ]); if (!$nameCheck->fetchColumn()) { break; } $copyName = $baseName . ' - COPY ' . $copyNumber; $copyNumber++; if ($copyNumber > 9999) { throw new RuntimeException( 'Could not generate a unique Storylet copy name.' ); } } $sourceKey = trim( (string)( $source['storylet_key'] ?? '' ) ); if ($sourceKey === '') { $sourceKey = 'storylet-' . $storyletId; } $copyKey = substr( $sourceKey, 0, 80 ) . '-copy-' . bin2hex( random_bytes(6) ); $copyUuid = bin2hex( random_bytes(16) ); $parentId = (int)( $source['parent_storylet_id'] ?? 0 ); $branchSortOrder = (int)( $source['branch_sort_order'] ?? 0 ); $choiceSortOrder = (int)( $source['choice_sort_order'] ?? 0 ); $branchLabel = (string)( $source['branch_label'] ?? '' ); $choiceLabel = (string)( $source['choice_label'] ?? '' ); if ($parentId > 0) { $orderStmt = $pdo->prepare( " SELECT COALESCE(MAX(branch_sort_order), 0) FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? " ); $orderStmt->execute([ $gameId, $parentId, ]); $branchSortOrder = (int)$orderStmt->fetchColumn() + 10; $choiceSortOrder = $branchSortOrder; if (trim($branchLabel) !== '') { $branchLabel = rtrim($branchLabel) . ' - COPY'; } if (trim($choiceLabel) !== '') { $choiceLabel = rtrim($choiceLabel) . ' - COPY'; } } $insert = $pdo->prepare( " INSERT INTO wf_storylets ( uuid, game_id, name, storylet_key, description, conditions, effects, max_occurrences, trigger_type, status, config, parent_storylet_id, choice_label, choice_sort_order, branch_label, branch_sort_order ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?) " ); $insert->execute([ $copyUuid, $gameId, $copyName, $copyKey, $source['description'] ?? null, $source['conditions'] ?? null, $source['effects'] ?? null, $source['max_occurrences'] ?? null, $source['trigger_type'] ?? 'manual', $source['config'] ?? null, $parentId > 0 ? $parentId : null, $choiceLabel !== '' ? $choiceLabel : null, $choiceSortOrder, $branchLabel, $branchSortOrder, ]); $copyId = (int)$pdo->lastInsertId(); if ($copyId <= 0) { throw new RuntimeException( 'The Storylet copy could not be created.' ); } /* Reuse the same game-level media assets; do not duplicate files. */ $media = $pdo->prepare( " INSERT INTO wf_media_links ( media_id, object_type, object_id, slot ) SELECT media_id, object_type, ?, slot FROM wf_media_links WHERE object_type = 'wf_storylets' AND object_id = ? " ); $media->execute([ $copyId, $storyletId, ]); $pdo->commit(); return [ 'duplicated' => true, 'id' => $copyId, 'error' => '', ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } return [ 'duplicated' => false, 'id' => 0, 'error' => $exception->getMessage(), ]; } } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } $traitType = $traitDefinition['type'] ?? 'text'; $initialMode = 'fixed'; $randomInitialMin = null; $randomInitialMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_random_initial']) ) { $initialMode = strtolower( trim( (string)( $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['initial_mode'] ?? 'fixed' ) ) ); if ( !in_array( $initialMode, ['fixed', 'random'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ': invalid initial value mode.'; break; } if ($initialMode === 'random') { $postedInitialMin = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['initial_min'] ?? ''; $postedInitialMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['initial_max'] ?? ''; if (is_string($postedInitialMin)) { $postedInitialMin = trim($postedInitialMin); } if (is_string($postedInitialMax)) { $postedInitialMax = trim($postedInitialMax); } if ( $postedInitialMin === '' || !is_numeric($postedInitialMin) ) { $error = ($traitDefinition['label'] ?? $traitName) . ': random initial minimum must be a number.'; break; } if ( $postedInitialMax === '' || !is_numeric($postedInitialMax) ) { $error = ($traitDefinition['label'] ?? $traitName) . ': random initial maximum must be a number.'; break; } $randomInitialMin = (float)$postedInitialMin; $randomInitialMax = (float)$postedInitialMax; if ($randomInitialMin > $randomInitialMax) { $error = ($traitDefinition['label'] ?? $traitName) . ': random initial minimum cannot be greater than maximum.'; break; } if ( isset($traitDefinition['min']) && $randomInitialMin < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ': random initial minimum must be at least ' . $traitDefinition['min'] . '.'; break; } if ( isset($traitDefinition['max']) && $randomInitialMax > (float)$traitDefinition['max'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ': random initial maximum cannot exceed ' . $traitDefinition['max'] . '.'; break; } } } if ( !empty($traitDefinition['required']) && $value === '' && $initialMode !== 'random' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } if ( $traitType === 'number' && $initialMode !== 'random' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $initialMode !== 'random' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } $startingMaximum = $initialMode === 'random' ? $randomInitialMax : (float)$value; if ( $startingMaximum !== null && (float)$startingMaximum > (float)$postedMax ) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTrait = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $initialMode === 'random' ? 0 : $value, $authorMax ); if ( $traitType === 'number' && !empty($traitDefinition['allow_random_initial']) ) { $systemTrait['initial_mode'] = $initialMode; if ($initialMode === 'random') { $systemTrait['initial_min'] = $randomInitialMin; $systemTrait['initial_max'] = $randomInitialMax; } } $systemTraits[] = $systemTrait; } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_LINEAR_TRAVEL_SAVE_V72 */ $regionTravel = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionTravelResult = buildRegionTravelFromPost( $record, isset($post['travel']) && is_array($post['travel']) ? $post['travel'] : [] ); $regionTravel = $regionTravelResult['travel'] ?? null; if ( ($regionTravelResult['error'] ?? '') !== '' ) { $error = (string)$regionTravelResult['error']; } } /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STATUS_LIFECYCLE_SAVE_V88 */ /* WAYFARER_STATUS_OWNER_SAVE_V92 */ $statusLifecycle = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_statuses' ) { $statusLifecycleResult = gameplayBuildStatusLifecycleFromPost( $pdo, $gameId, $post['status_lifecycle'] ?? [], (string)($configValues['applies_to'] ?? 'character') ); $statusLifecycle = $statusLifecycleResult['lifecycle'] ?? null; $record['_status_lifecycle_editor'] = is_array( $statusLifecycleResult['editor'] ?? null ) ? $statusLifecycleResult['editor'] : []; if ( ($statusLifecycleResult['error'] ?? '') !== '' ) { $error = (string)$statusLifecycleResult['error']; } } /* WAYFARER_STORYLET_PARSE_V56 */ /* WAYFARER_STORYLET_BINDINGS_V79 */ $storyletBindings = []; $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $bindingResult = gameplayBuildStoryletBindingsFromPost( isset($post['bindings']) && is_array($post['bindings']) ? array_values($post['bindings']) : [] ); $storyletBindings = $bindingResult['bindings']; if ($bindingResult['error'] !== '') { $error = $bindingResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [], $storyletBindings, $postedId ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial_mode' => (string)( $systemTrait['initial_mode'] ?? 'fixed' ), 'initial' => $systemTrait['initial'] ?? 0, 'initial_min' => $systemTrait['initial_min'] ?? null, 'initial_max' => $systemTrait['initial_max'] ?? null, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_LINEAR_TRAVEL_SAVE_V72 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTravel !== null) { $configToSave['travel'] = $regionTravel; } else { unset( $configToSave['travel'] ); } } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STATUS_LIFECYCLE_CONFIG_V88 */ if ( ($definition['table'] ?? '') === 'wf_statuses' ) { if (is_array($statusLifecycle)) { $configToSave['lifecycle'] = $statusLifecycle; } else { unset($configToSave['lifecycle']); } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { /* WAYFARER_STORYLET_BINDINGS_V79 */ if ($storyletBindings) { $configToSave['bindings'] = array_values($storyletBindings); } else { unset($configToSave['bindings']); } if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } /* * WAYFARER_STORYLET_INTERNAL_IDS_V91 * * uuid and storylet_key are structural Storylet identifiers, not * author-facing Builder fields. The generic INSERT therefore has to add * them explicitly for a new wf_storylets row. */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { $storyletUuid = bin2hex( random_bytes(16) ); $storyletKey = 'storylet-' . substr( $storyletUuid, 0, 24 ); $columns[] = '`uuid`'; $placeholders[] = '?'; $values[] = $storyletUuid; $columns[] = '`storylet_key`'; $placeholders[] = '?'; $values[] = $storyletKey; /* * Keep returned editor state coherent even though these values are * intentionally not displayed as authored fields. */ $record['uuid'] = $storyletUuid; $record['storylet_key'] = $storyletKey; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; /* * WAYFARER_STORYLET_UUID_SAVE_V90 * * Storylet UUID is an internal persistent identifier, not an authored * form value. Older/new-record builder state can therefore carry an * empty string here. Never send that empty value to the UNIQUE * wf_storylets.uuid column; create the identifier at insert time. * * Existing Storylet edits keep their stored UUID unchanged because * this branch runs only in the new-record INSERT path. */ if ( ($definition['table'] ?? '') === 'wf_storylets' && $fieldName === 'uuid' && trim((string)$value) === '' ) { $value = bin2hex( random_bytes(16) ); $record[$fieldName] = $value; } if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: _wayfarer_backups/v94-20260930-170953/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* |-------------------------------------------------------------------------- | Party-member Trait catalog | WAYFARER_PARTY_TARGETS_V85 |-------------------------------------------------------------------------- | | Player Traits and authored Character Entity Traits may represent the same | logical per-person property (for example, Health). Party-level authoring | addresses that property by normalized name so one Effect can operate across | the current party without exposing runtime player-slot numbers. | */ function gameplayPartyTraitNormalizeName( string $value ): string { $value = trim( preg_replace( '/\\s+/u', ' ', $value ) ?? $value ); if (function_exists('mb_strtolower')) { return mb_strtolower( $value, 'UTF-8' ); } return strtolower($value); } function gameplayPartyTraitTargetId( string $traitName ): string { return 'party_name_' . bin2hex( gameplayPartyTraitNormalizeName( $traitName ) ); } function gameplayPartyTraitCatalog( PDO $pdo, int $gameId, ?array $playerTraitsOverride = null ): array { if ($gameId <= 0) { return []; } $catalog = []; $append = static function ( array &$catalog, array $trait ): void { if ( !is_array($trait) || !empty($trait['system']) || ( array_key_exists('active', $trait) && !$trait['active'] ) ) { return; } $name = trim( (string)( $trait['name'] ?? '' ) ); if ($name === '') { return; } $type = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); if ($type === 'binary') { $type = 'boolean'; } if ( !in_array( $type, ['number', 'boolean'], true ) ) { return; } $normalized = gameplayPartyTraitNormalizeName( $name ); if ($normalized === '') { return; } if (!isset($catalog[$normalized])) { $catalog[$normalized] = [ 'name' => $name, 'data_type' => $type, 'conflict' => false, ]; return; } if ( (string)$catalog[$normalized]['data_type'] !== $type ) { $catalog[$normalized]['conflict'] = true; } }; if ($playerTraitsOverride === null) { $stmt = $pdo->prepare( 'SELECT config FROM wf_games WHERE id = ? LIMIT 1' ); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); $config = $raw === false ? [] : gameplayRuntimeStateDecodeConfig( $raw ); $playerTraits = isset($config['player_traits']) && is_array($config['player_traits']) ? $config['player_traits'] : []; } else { $playerTraits = $playerTraitsOverride; } foreach ($playerTraits as $trait) { if (is_array($trait)) { $append( $catalog, $trait ); } } $stmt = $pdo->prepare( " SELECT config FROM wf_entities WHERE game_id = ? AND status = 'active' AND LOWER(entity_type) = 'character' ORDER BY id " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (is_array($trait)) { $append( $catalog, $trait ); } } } foreach ($catalog as $normalized => $definition) { if (!empty($definition['conflict'])) { unset($catalog[$normalized]); } } uasort( $catalog, static function ( array $a, array $b ): int { return strcasecmp( (string)$a['name'], (string)$b['name'] ); } ); return $catalog; } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } /* |-------------------------------------------------------------------------- | Status definitions / target metadata | WAYFARER_STATUSES_AUTHORING_V87 | WAYFARER_STATUS_OWNERS_V92 |-------------------------------------------------------------------------- */ function gameplayStatusDefinitions( PDO $pdo, int $gameId, bool $activeOnly = true ): array { if ($gameId <= 0) { return []; } $sql = " SELECT id, name, description, config, status FROM wf_statuses WHERE game_id = ? "; if ($activeOnly) { $sql .= " AND status = 'active'"; } $sql .= " ORDER BY name, id"; $stmt = $pdo->prepare($sql); $stmt->execute([$gameId]); $definitions = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $normalizeBoolean = static function ($value, bool $default): bool { if ($value === null || $value === '') { return $default; } if (is_bool($value)) { return $value; } return in_array( strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true ); }; $definitions[(string)$id] = [ 'id' => $id, 'name' => trim((string)($row['name'] ?? ('Status ' . $id))), 'description' => trim((string)($row['description'] ?? '')), 'status' => strtolower(trim((string)($row['status'] ?? 'draft'))), /* WAYFARER_STATUS_OWNERS_V92 */ 'applies_to' => in_array( strtolower(trim((string)($config['applies_to'] ?? 'character'))), ['character', 'item', 'both'], true ) ? strtolower(trim((string)($config['applies_to'] ?? 'character'))) : 'character', 'show_to_player' => $normalizeBoolean( $config['show_to_player'] ?? null, true ), /* WAYFARER_STATUS_LIFECYCLE_AUTHORING_V88 */ 'show_stage_to_player' => $normalizeBoolean( $config['show_stage_to_player'] ?? null, true ), 'show_time_remaining' => $normalizeBoolean( $config['show_time_remaining'] ?? null, false ), 'lifecycle' => isset($config['lifecycle']) && is_array($config['lifecycle']) ? $config['lifecycle'] : null, ]; } return $definitions; } /* WAYFARER_STATUS_OWNERS_V92 */ function gameplayStatusAppliesTo( array $definition, string $ownerType ): bool { $appliesTo = strtolower( trim((string)($definition['applies_to'] ?? 'character')) ); $ownerType = strtolower(trim($ownerType)); if ($appliesTo === 'both') { return in_array($ownerType, ['character', 'item'], true); } return $appliesTo === $ownerType; } function gameplayStatusTargetDefinition( string $scope, int $statusId, string $statusName, array $hierarchy, array $extra = [] ): array { return array_merge( [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'status', 'target_id' => (string)$statusId, 'trait_type' => 'boolean', 'data_type' => 'boolean', 'semantic_type' => 'status', 'helper' => 'Persistent Status: Active / Inactive', 'condition_operators' => [ 'equals', 'not_equals', ], 'effect_operations' => [ 'set', ], 'supports_random' => false, 'value_options' => [ '0' => 'Inactive', '1' => 'Active', ], 'group' => 'Statuses', 'label' => $statusName, 'hierarchy' => $hierarchy, ], $extra ); } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } /* WAYFARER_PARTY_TARGETS_V85 */ foreach ( gameplayPartyTraitCatalog( $pdo, $gameId, $playerTraits ) as $partyTrait ) { $partyName = (string)( $partyTrait['name'] ?? '' ); $partyDataType = (string)( $partyTrait['data_type'] ?? 'text' ); if ( $partyName === '' || !in_array( $partyDataType, ['number', 'boolean'], true ) ) { continue; } $partyTargetId = gameplayPartyTraitTargetId( $partyName ); $partyKey = 'party:trait:' . $partyTargetId; $targets[$partyKey] = [ 'scope' => 'party', 'target_scope' => 'party', 'target_type' => 'trait', 'target_id' => $partyTargetId, 'trait_type' => $partyDataType, 'data_type' => $partyDataType, 'semantic_type' => 'party_all_trait', 'helper' => 'Every current party member that exposes this Trait', 'condition_operators' => $partyDataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ], 'effect_operations' => $partyDataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ], 'supports_random' => $partyDataType === 'number', /* * A Party Condition needs an explicit Any/All quantifier model. * Until that exists, this aggregate target is Effect-only. */ 'condition_hidden' => true, 'group' => 'Party', 'label' => 'All Party Members → ' . $partyName, 'hierarchy' => [ [ 'key' => 'party', 'label' => 'Party', ], [ 'key' => 'all_party_members', 'label' => 'All Party Members', ], [ 'key' => $partyTargetId, 'label' => $partyName, ], ], ]; if ($partyDataType === 'boolean') { $targets[$partyKey]['value_options'] = [ '0' => 'False', '1' => 'True', ]; } } /* WAYFARER_STATUS_TARGETS_V87 */ $statusDefinitions = gameplayStatusDefinitions( $pdo, $gameId, true ); foreach ($statusDefinitions as $statusDefinition) { $statusId = (int)($statusDefinition['id'] ?? 0); $statusName = trim((string)($statusDefinition['name'] ?? '')); if ( $statusId <= 0 || $statusName === '' || !gameplayStatusAppliesTo($statusDefinition, 'character') ) { continue; } $partyStatusKey = 'party:status:' . $statusId; $targets[$partyStatusKey] = gameplayStatusTargetDefinition( 'party', $statusId, $statusName, [ [ 'key' => 'party', 'label' => 'Party', ], [ 'key' => 'all_party_members', 'label' => 'All Party Members', ], [ 'key' => 'status:' . $statusId, 'label' => 'Status — ' . $statusName, ], ], [ /* Party Status Conditions need explicit Any/All semantics. */ 'condition_hidden' => true, 'group' => 'Party', 'label' => 'All Party Members → Status — ' . $statusName, ] ); for ($slot = 0; $slot < $playerSlots; $slot++) { $playerStatusKey = 'player:' . $slot . ':status:' . $statusId; $targets[$playerStatusKey] = gameplayStatusTargetDefinition( 'player:' . $slot, $statusId, $statusName, [ [ 'key' => 'player', 'label' => 'Player Character', ], [ 'key' => 'player:' . $slot, 'label' => 'Player ' . ($slot + 1), ], [ 'key' => 'status:' . $statusId, 'label' => 'Status — ' . $statusName, ], ], [ /* Player slots remain a runtime compatibility address. */ 'condition_hidden' => true, 'effect_hidden' => true, ] ); } } /* * Numbered Player slots remain runtime-compatible for existing authored * data, but they are implementation slots rather than useful new targets. * New authoring uses Storylet Binding -> Random Party Member or * Party -> All Party Members instead. */ for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); $playerKey = 'player:' . $slot . ':trait:' . $traitId; if ( $traitId !== '' && isset($targets[$playerKey]) ) { $targets[$playerKey]['condition_hidden'] = true; $targets[$playerKey]['effect_hidden'] = true; } } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } /* WAYFARER_STATUS_OWNERS_V92 */ if (in_array($entityType, ['character', 'item'], true)) { foreach ($statusDefinitions as $statusDefinition) { $statusId = (int)($statusDefinition['id'] ?? 0); $statusName = trim((string)($statusDefinition['name'] ?? '')); if ( $statusId <= 0 || $statusName === '' || !gameplayStatusAppliesTo($statusDefinition, $entityType) ) { continue; } $statusKey = 'entity:' . $id . ':status:' . $statusId; $ownerLabel = $entityType === 'item' ? 'Item' : 'Character'; $groupLabel = $entityType === 'item' ? 'Items' : 'Characters'; $targets[$statusKey] = gameplayStatusTargetDefinition( 'entity:' . $id, $statusId, $statusName, [ [ 'key' => $entityType, 'label' => $ownerLabel, ], [ 'key' => 'entity:' . $id, 'label' => $name, ], [ 'key' => 'status:' . $statusId, 'label' => 'Status — ' . $statusName, ], ], [ 'group' => $groupLabel, 'label' => $name . ' → Status — ' . $statusName, ] ); } } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ /* |-------------------------------------------------------------------------- | Navigation Condition Targets |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITION_TARGETS_V75 | WAYFARER_LIVE_LOCATION_ANCHORS_V76 | | Numeric Position Conditions are Region-qualified. | | New Location Conditions reference wf_locations.id as target_id. The | comparison itself is against zero because runtime resolves the target to: | | current position - current anchor position | | That makes a Location a live spatial anchor: | | = 0 => At | != 0 => Not at | < 0 => Before | <= 0 => At or before | > 0 => After | >= 0 => At or after | | The V75 snapshot target navigation:location:location remains hidden for | backward compatibility only. | */ function gameplayConditionNavigationPositionToken( $value ): string { if (!is_numeric($value)) { return ''; } $number = (float)$value; if (is_finite($number) && floor($number) == $number) { return (string)((int)$number); } $token = rtrim( rtrim( sprintf('%.12F', $number), '0' ), '.' ); return $token === '-0' ? '0' : $token; } function gameplayNavigationConditionTargets( PDO $pdo, int $gameId ): array { $targets = []; /* * Compatibility only. Existing saved unqualified Position Conditions * still render and save, but cannot be newly authored. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position_legacy', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'group' => 'Navigation', 'label' => 'Position (legacy unqualified)', 'condition_hidden' => true, ]; /* * Compatibility only. V75 stored Region + Position in the value itself. * New V76 Conditions never use this target. */ $targets['navigation:location:location'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => 'location', 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'navigation_location_v75_snapshot', 'helper' => 'V75 snapshot', 'condition_operators' => [ 'equals', 'not_equals', ], 'group' => 'Navigation', 'label' => 'Location (legacy V75 snapshot)', 'condition_hidden' => true, ]; if ($gameId <= 0) { return $targets; } /* * Fixed numeric Position, explicitly qualified to one linear Region. */ $regionStmt = $pdo->prepare( " SELECT id, name, navigation_type FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $regionStmt->execute([ $gameId ]); foreach ( $regionStmt->fetchAll(PDO::FETCH_ASSOC) as $region ) { $regionId = (int)($region['id'] ?? 0); if ($regionId <= 0) { continue; } if ( strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) !== 'linear' ) { continue; } $regionName = trim( (string)( $region['name'] ?? ('Region ' . $regionId) ) ); $scope = 'navigation:' . $regionId; $key = $scope . ':position:position'; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_region_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'group' => 'Navigation — Positions', 'label' => $regionName, ]; } /* * Named Locations are LIVE references. Each Location is its own target, * identified by wf_locations.id. Position is shown only as authoring help; * it is resolved again from current Location config at runtime. */ $locationStmt = $pdo->prepare( " SELECT l.id, l.name, l.config, l.region_id, r.name AS region_name, r.navigation_type FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $locationStmt->execute([ $gameId ]); foreach ( $locationStmt->fetchAll(PDO::FETCH_ASSOC) as $location ) { if ( strtolower( trim( (string)( $location['navigation_type'] ?? '' ) ) ) !== 'linear' ) { continue; } $locationId = (int)( $location['id'] ?? 0 ); if ($locationId <= 0) { continue; } $config = gameplayRuntimeStateDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $position = gameplayConditionNavigationPositionToken( $config['position'] ); if ($position === '') { continue; } $locationName = trim( (string)( $location['name'] ?? ('Location ' . $locationId) ) ); $regionName = trim( (string)( $location['region_name'] ?? 'Region' ) ); $key = 'navigation:location:' . $locationId; $targets[$key] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => (string)$locationId, 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_location_anchor', 'helper' => $regionName . ' · Position ' . $position, 'condition_operators' => [ 'equals', 'not_equals', 'less_than', 'less_than_or_equal', 'greater_than', 'greater_than_or_equal', ], 'condition_operator_labels' => [ 'equals' => 'At', 'not_equals' => 'Not at', 'less_than' => 'Before', 'less_than_or_equal' => 'At or before', 'greater_than' => 'After', 'greater_than_or_equal' => 'At or after', ], /* * The runtime value is a signed delta from this anchor, so every * comparison is against zero. Keep that implementation detail out * of the authoring UI while still posting a normal Condition value. */ 'value_options' => [ '0' => 'Anchor', ], 'condition_hide_value' => true, 'group' => 'Navigation — Locations', 'label' => $locationName, ]; } return $targets; } /* * Allows Condition POST validation to recognize V76 navigation-only target * shapes even when a caller supplied the older universal Effect catalog. */ /* |-------------------------------------------------------------------------- | Storylet Navigation Effect targets | WAYFARER_STORYLET_ROOT_CONNECTIONS_V82 |-------------------------------------------------------------------------- | | Explicit Storylet navigation is an Effect-only target. A Storylet may | continue directly to another root Storylet after its other Effects resolve. | Child Storylets remain structural Branches and are deliberately not exposed | as destinations here. */ function gameplayStoryletNavigationEffectTargets( PDO $pdo, int $gameId, int $currentStoryletId = 0 ): array { $targets = []; if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, status FROM wf_storylets WHERE game_id = ? AND ( parent_storylet_id IS NULL OR parent_storylet_id = 0 ) ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $storylet ) { $storyletId = (int)( $storylet['id'] ?? 0 ); if ( $storyletId <= 0 || ( $currentStoryletId > 0 && $storyletId === $currentStoryletId ) ) { continue; } $name = trim( (string)( $storylet['name'] ?? '' ) ); if ($name === '') { $name = 'Storylet ' . $storyletId; } $status = strtolower( trim( (string)( $storylet['status'] ?? '' ) ) ); $helper = $status !== '' ? ucfirst($status) . ' root Storylet' : 'Root Storylet'; $key = 'storylet:transition:' . $storyletId; $targets[$key] = [ 'scope' => 'storylet', 'target_scope' => 'storylet', 'target_type' => 'transition', 'target_id' => (string)$storyletId, 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'storylet_transition', 'helper' => $helper, 'effect_operations' => [ 'continue', ], 'supports_random' => false, 'group' => 'Storylet Navigation', 'label' => $name . ' → Continue To', 'hierarchy' => [ [ 'key' => 'storylet', 'label' => 'Storylet', ], [ 'key' => 'storylet:' . $storyletId, 'label' => $name, ], [ 'key' => 'continue_to', 'label' => 'Continue To', ], ], ]; } return $targets; } /* |-------------------------------------------------------------------------- | Hierarchical target-ready Time Conditions | WAYFARER_HIERARCHICAL_TARGETS_V80 |-------------------------------------------------------------------------- | | The hierarchy UI consumes a generic target catalog. New systems can opt in | simply by supplying a three-level hierarchy array: | | Type -> Specific Target -> Property | | Current Game Time is Condition-only. Effects do not modify the clock here. | Calendar / Seasons can later register additional Time properties without | changing the picker. */ function gameplayTimeConditionTargets( PDO $pdo, int $gameId ): array { $targets = []; if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ($raw === false) { return $targets; } $config = gameplayRuntimeStateDecodeConfig( $raw ); $time = isset($config['time']) && is_array($config['time']) ? $config['time'] : []; $mode = strtolower( trim( (string)( $time['mode'] ?? 'none' ) ) ); if ( $mode !== 'days' && $mode !== 'hours' ) { return $targets; } $base = [ 'scope' => 'time', 'target_scope' => 'time', 'target_type' => 'clock', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'game_time', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'group' => 'Time', ]; $targets['time:clock:day'] = array_merge( $base, [ 'target_id' => 'day', 'label' => 'Game Clock → Day', 'hierarchy' => [ [ 'key' => 'time', 'label' => 'Time', ], [ 'key' => 'game_clock', 'label' => 'Game Clock', ], [ 'key' => 'day', 'label' => 'Day', ], ], ] ); if ($mode === 'hours') { $targets['time:clock:hour'] = array_merge( $base, [ 'target_id' => 'hour', 'label' => 'Game Clock → Hour', 'hierarchy' => [ [ 'key' => 'time', 'label' => 'Time', ], [ 'key' => 'game_clock', 'label' => 'Game Clock', ], [ 'key' => 'hour', 'label' => 'Hour', ], ], ] ); } return $targets; } function gameplaySyntheticTimeConditionTarget( string $target ): ?array { if ($target === 'time:clock:day') { return [ 'scope' => 'time', 'target_scope' => 'time', 'target_type' => 'clock', 'target_id' => 'day', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'game_time', 'condition_operators' => wayfarerConditionOperators(), ]; } if ($target === 'time:clock:hour') { return [ 'scope' => 'time', 'target_scope' => 'time', 'target_type' => 'clock', 'target_id' => 'hour', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'game_time', 'condition_operators' => wayfarerConditionOperators(), ]; } return null; } function gameplaySyntheticNavigationConditionTarget( string $target ): ?array { if ( preg_match( '/^navigation:([1-9][0-9]*):position:position$/', $target, $match ) === 1 ) { $scope = 'navigation:' . (int)$match[1]; return [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_region_position', 'condition_operators' => wayfarerConditionOperators(), ]; } if ( preg_match( '/^navigation:location:([1-9][0-9]*)$/', $target, $match ) === 1 ) { return [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => (string)((int)$match[1]), 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_location_anchor', 'condition_operators' => [ 'equals', 'not_equals', 'less_than', 'less_than_or_equal', 'greater_than', 'greater_than_or_equal', ], ]; } /* V75 snapshot compatibility. */ if ($target === 'navigation:location:location') { return [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => 'location', 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'navigation_location_v75_snapshot', 'condition_operators' => [ 'equals', 'not_equals', ], ]; } return null; } function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return array_replace( gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ), gameplayNavigationConditionTargets( $pdo, $gameId ), gameplayTimeConditionTargets( $pdo, $gameId ) ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; /* * WAYFARER_LOCATION_CONDITION_TARGETS_V75 * * Older callers may still pass the universal Effect target catalog. * Region-qualified Position and Location targets are Condition-only, * so synthesize their definitions here when needed. */ if ( $target !== '' && !isset($allowedTargets[$target]) ) { $syntheticTarget = gameplaySyntheticNavigationConditionTarget( $target ); if ( $syntheticTarget === null && function_exists( 'gameplaySyntheticTimeConditionTarget' ) ) { $syntheticTarget = gameplaySyntheticTimeConditionTarget( $target ); } if ($syntheticTarget !== null) { $allowedTargets[$target] = $syntheticTarget; } } if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: _wayfarer_backups/v94-20260930-170953/play/assets/play.css ================================================================================ /* |-------------------------------------------------------------------------- | Wayfarer Play Shell v20 |-------------------------------------------------------------------------- */ html, body { height: 100%; } body.wayfarer-play-page { margin: 0; overflow: hidden; background: #eef1f5; color: #20242a; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.45; } .wayfarer-play-page * { box-sizing: border-box; } .wayfarer-play-page hr { display: none; } .wayfarer-play-shell { height: 100vh; display: flex; flex-direction: column; overflow: hidden; } .wayfarer-play-shell-header { flex: 0 0 auto; min-height: 68px; padding: 12px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; background: #ffffff; border-bottom: 1px solid #d8dbe2; } .wayfarer-play-title { min-width: 0; } .wayfarer-play-title h1 { margin: 0; font-size: 1.35rem; line-height: 1.2; } .wayfarer-play-title p { margin: 4px 0 0; color: #667085; font-size: 0.9rem; } .wayfarer-shell-actions { display: flex; align-items: center; gap: 8px; } .wayfarer-shell-button { border: 1px solid #b8bdc7; border-radius: 6px; padding: 7px 10px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 600; } .wayfarer-shell-button:hover { background: #f6f7f9; } .wayfarer-play-main { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 7fr) minmax(330px, 3fr); gap: 12px; padding: 12px; overflow: hidden; } .wayfarer-player-pane, .wayfarer-author-pane { min-width: 0; min-height: 0; background: #ffffff; border: 1px solid #d8dbe2; border-radius: 10px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); } .wayfarer-player-pane { overflow: auto; padding: 22px; } .wayfarer-player-pane > h2:first-child { margin-top: 0; } #wayfarer-player-scene { margin-top: 10px; } #wayfarer-player-scene h2 { margin-top: 0; } .wayfarer-author-pane { display: flex; flex-direction: column; overflow: hidden; } .wayfarer-author-header { flex: 0 0 auto; padding: 14px 16px 10px; border-bottom: 1px solid #e3e6eb; } .wayfarer-author-header h2 { margin: 0; font-size: 1.05rem; } .wayfarer-author-header p { margin: 6px 0 0; font-size: 0.85rem; } .wayfarer-author-header a { color: #2f5ea8; } .wayfarer-author-toolbar { flex: 0 0 auto; padding: 10px 14px; background: #f6f7f9; border-bottom: 1px solid #e3e6eb; } .wayfarer-author-toolbar #wayfarer-tick-test { margin: 0; } .wayfarer-author-toolbar #wayfarer-tick-test > h2 { margin: 0 0 6px; font-size: 0.9rem; } .wayfarer-author-toolbar #wayfarer-tick-test > p { margin: 5px 0; font-size: 0.82rem; } .wayfarer-author-toolbar button { margin-right: 4px; margin-bottom: 2px; padding: 5px 8px; } #wayfarer-tick-message { min-height: 1.2em; } .wayfarer-author-tabs { flex: 0 0 auto; display: flex; gap: 2px; padding: 8px 10px 0; background: #ffffff; border-bottom: 1px solid #d8dbe2; } .wayfarer-author-tab { appearance: none; border: 0; border-bottom: 2px solid transparent; padding: 8px 10px 7px; background: transparent; color: #667085; cursor: pointer; font: inherit; font-size: 0.86rem; font-weight: 600; } .wayfarer-author-tab:hover { color: #20242a; } .wayfarer-author-tab.is-active { color: #2f5ea8; border-bottom-color: #2f5ea8; } .wayfarer-author-content { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 14px 16px 18px; } .wayfarer-author-tab-panel { display: none; } .wayfarer-author-tab-panel.is-active { display: block; } .wayfarer-author-tab-panel > :first-child { margin-top: 0; } .wayfarer-author-tab-panel h3 { margin-top: 0; font-size: 1rem; } .wayfarer-author-tab-panel h4 { margin-bottom: 8px; } .wayfarer-author-tab-panel table { width: 100%; border-collapse: collapse; font-size: 0.84rem; } .wayfarer-author-tab-panel th, .wayfarer-author-tab-panel td { padding: 6px 7px; text-align: left; vertical-align: top; } .wayfarer-author-tab-panel pre { max-width: 100%; overflow: auto; font-size: 0.78rem; } #wayfarer-author-debug-panel details { margin-top: 0 !important; } #wayfarer-author-debug-panel summary { display: none; } #wayfarer-author-debug-panel details:not([open]) > *:not(summary) { display: block; } #wayfarer-author-debug-panel details > pre { display: block; max-height: none !important; margin-top: 0 !important; } .wayfarer-player-meta { margin-bottom: 14px; padding: 10px 12px; border-radius: 7px; background: #f6f7f9; color: #667085; font-size: 0.86rem; } .wayfarer-player-meta h2 { margin: 0 0 4px; color: #20242a; font-size: 0.95rem; } .wayfarer-player-meta p { margin: 0; } .wayfarer-play-shell.author-collapsed .wayfarer-play-main { grid-template-columns: minmax(0, 1fr); } .wayfarer-play-shell.author-collapsed .wayfarer-author-pane { display: none; } @media (max-width: 900px) { body.wayfarer-play-page { overflow: auto; } .wayfarer-play-shell { height: auto; min-height: 100vh; overflow: visible; } .wayfarer-play-main { display: block; overflow: visible; padding: 8px; } .wayfarer-player-pane, .wayfarer-author-pane { overflow: visible; margin-bottom: 10px; } .wayfarer-author-pane { min-height: 480px; } .wayfarer-author-content { overflow: visible; } .wayfarer-play-shell.author-collapsed .wayfarer-author-pane { display: none; } } /* ------------------------------------------------------------------ GAME START SCREEN WAYFARER_PLAYER_START_SCREEN_V36 ------------------------------------------------------------------ */ /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * The start screen no longer implies "no Author Mode." Authors may inspect * the Author panel before initialization. The existing author-collapsed class * controls the one-column layout for normal players and collapsed authors. */ .wayfarer-start-screen { width: min(860px, 100%); margin: 0 auto; } .wayfarer-start-screen-heading { margin-bottom: 24px; } .wayfarer-start-screen-heading h2 { margin: 0 0 12px; font-size: 1.65rem; } .wayfarer-start-introduction { max-width: 72ch; color: #4b5563; line-height: 1.65; } .wayfarer-start-error { margin-bottom: 18px; padding: 10px 12px; border: 1px solid #e4a7ad; border-radius: 7px; background: #fff4f5; color: #8b1e2d; } .wayfarer-start-form { display: grid; gap: 22px; } .wayfarer-start-section, .wayfarer-start-choice { margin: 0; padding: 18px; border: 1px solid #d8dbe2; border-radius: 9px; background: #ffffff; } .wayfarer-start-section h3, .wayfarer-start-choice legend { margin: 0 0 14px; color: #20242a; font-size: 1.05rem; font-weight: 700; } .wayfarer-start-choice legend { padding: 0 6px; } .wayfarer-start-names { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } .wayfarer-start-names label { display: grid; gap: 5px; font-weight: 600; } .wayfarer-start-names input { width: 100%; padding: 9px 10px; border: 1px solid #b8bdc7; border-radius: 6px; font: inherit; } .wayfarer-start-choice { display: grid; gap: 10px; } .wayfarer-start-option { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: start; padding: 12px 13px; border: 1px solid #d8dbe2; border-radius: 7px; cursor: pointer; } .wayfarer-start-option:hover { background: #f6f7f9; } .wayfarer-start-option input { margin-top: 4px; } .wayfarer-start-option-copy { display: grid; gap: 4px; } .wayfarer-start-option-copy > span { color: #667085; font-size: 0.92rem; } .wayfarer-start-actions { display: flex; justify-content: flex-end; } .wayfarer-start-button { padding: 10px 18px; border: 1px solid #244b88; border-radius: 7px; background: #2f5ea8; color: #ffffff; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-start-button:hover { background: #244b88; } @media (max-width: 700px) { .wayfarer-start-screen { width: 100%; } .wayfarer-start-section, .wayfarer-start-choice { padding: 14px; } } /* ------------------------------------------------------------------ PLAYER ACTION SCREEN WAYFARER_ACTION_SCREEN_V39 ------------------------------------------------------------------ */ .wayfarer-action-screen { margin-top: 14px; padding: 18px; border: 1px solid #d8dbe2; border-radius: 10px; background: #ffffff; box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); } .wayfarer-action-screen[hidden], .wayfarer-travel-controls[hidden] { display: none !important; } .wayfarer-action-heading { margin-bottom: 14px; } .wayfarer-action-heading h2 { margin: 0 0 4px; font-size: 1.25rem; } .wayfarer-action-heading p { margin: 0; color: #667085; } .wayfarer-action-list { display: grid; gap: 10px; } .wayfarer-action-button { width: 100%; padding: 12px 14px; border: 1px solid #214b86; border-radius: 8px; background: #245a9b; color: #ffffff; cursor: pointer; font: inherit; font-weight: 700; text-align: left; } .wayfarer-action-button:hover { background: #1e4c84; } .wayfarer-action-button:disabled { cursor: default; opacity: 0.55; } /* ------------------------------------------------------------------ IN-CARD ACTION UTILITY VIEWS WAYFARER_ACTION_UTILITY_VIEWS_V48 ------------------------------------------------------------------ */ .wayfarer-action-utility-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 2px; } .wayfarer-action-utility-button, .wayfarer-action-back-button { padding: 10px 12px; border: 1px solid #b8bdc7; border-radius: 7px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 650; } .wayfarer-action-utility-button { width: 100%; text-align: center; } .wayfarer-action-utility-button:hover, .wayfarer-action-back-button:hover { border-color: #98a2b3; background: #f6f7f9; } .wayfarer-action-subview[hidden], .wayfarer-action-heading[hidden], .wayfarer-action-list[hidden] { display: none !important; } .wayfarer-action-subview-heading { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 18px; } .wayfarer-action-subview-heading h2 { margin: 0; font-size: 1.25rem; } .wayfarer-action-back-button { flex: 0 0 auto; } .wayfarer-action-subview-body { min-height: 90px; } /* ------------------------------------------------------------------ ENTITY INVENTORY / STATUS PRESENTATION WAYFARER_ENTITY_PRESENTATION_V49 ------------------------------------------------------------------ */ .wayfarer-inventory-list, .wayfarer-party-list { display: grid; gap: 10px; } .wayfarer-inventory-item, .wayfarer-party-member, .wayfarer-status-section { padding: 13px 14px; border: 1px solid #d8dbe2; border-radius: 8px; background: #f8fafc; } .wayfarer-inventory-item-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; } .wayfarer-inventory-quantity { flex: 0 0 auto; color: #667085; font-size: 0.9rem; font-weight: 650; } .wayfarer-status-section + .wayfarer-status-section { margin-top: 12px; } .wayfarer-status-section > h3 { margin: 0 0 10px; font-size: 1rem; } .wayfarer-status-player-name { margin: 0; font-weight: 700; } .wayfarer-party-member { background: #ffffff; } .wayfarer-party-member h4 { margin: 0; font-size: 0.98rem; } /* WAYFARER_PLAYER_TRAITS_STATUS_V52 */ .wayfarer-player-member h4::after { content: "Player"; margin-left: 8px; color: #667085; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; } .wayfarer-status-values { margin: 10px 0 0; } .wayfarer-status-values > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; padding: 6px 0; border-top: 1px solid #e3e6eb; } .wayfarer-status-values dt { color: #4b5563; } .wayfarer-status-values dd { margin: 0; font-weight: 700; text-align: right; } .wayfarer-status-muted { margin: 8px 0 0; color: #667085; font-size: 0.88rem; } /* ------------------------------------------------------------------ ENTITY DETAIL VIEW WAYFARER_ENTITY_DETAIL_VIEW_V50 ------------------------------------------------------------------ */ .wayfarer-entity-open-button { appearance: none; border: 0; padding: 0; background: transparent; color: #2f5ea8; cursor: pointer; font: inherit; font-weight: 750; text-align: left; } .wayfarer-entity-open-button:hover { text-decoration: underline; } .wayfarer-entity-open-button:focus-visible { outline: 2px solid #2f5ea8; outline-offset: 3px; border-radius: 2px; } .wayfarer-status-player-names { display: grid; gap: 4px; } .wayfarer-status-player-names .wayfarer-status-player-name { margin: 0; } .wayfarer-entity-detail-eyebrow { margin: 0 0 3px; color: #667085; font-size: 0.78rem; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; } .wayfarer-entity-detail-layout { display: grid; grid-template-columns: minmax(180px, 280px) minmax(0, 1fr); gap: 22px; align-items: start; } .wayfarer-entity-detail-layout:not(:has(.wayfarer-entity-detail-media)) { grid-template-columns: minmax(0, 1fr); } .wayfarer-entity-detail-media { margin: 0; } .wayfarer-entity-detail-media img { display: block; width: 100%; max-height: 340px; object-fit: contain; border-radius: 8px; background: #f6f7f9; } .wayfarer-entity-detail-description { margin: 0 0 16px; line-height: 1.6; } .wayfarer-entity-detail-copy > :first-child { margin-top: 0; } @media (max-width: 700px) { .wayfarer-entity-detail-layout { grid-template-columns: 1fr; } .wayfarer-entity-detail-media img { max-height: 280px; } } .wayfarer-action-empty { margin: 0; color: #667085; font-size: 0.92rem; } .wayfarer-travel-controls { margin-top: 14px; padding: 12px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid #d8dbe2; border-radius: 10px; background: #ffffff; } .wayfarer-travel-status { display: flex; align-items: center; gap: 9px; font-weight: 700; } /* WAYFARER_TRAVEL_TIME_HUD_V61 */ .wayfarer-travel-copy { min-width: 0; } .wayfarer-travel-primary { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px; } .wayfarer-travel-time { font-weight: 800; } .wayfarer-travel-time-separator { color: #98a2b3; } .wayfarer-travel-next { margin-top: 3px; color: #667085; font-size: 0.88rem; font-weight: 600; } .wayfarer-travel-indicator { width: 9px; height: 9px; border-radius: 999px; background: currentColor; } .wayfarer-travel-pause { padding: 8px 12px; border: 1px solid #aab4c3; border-radius: 7px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 600; } .wayfarer-travel-pause:hover { background: #f6f7f9; } @media (max-width: 620px) { .wayfarer-action-screen { padding: 14px; } .wayfarer-travel-controls { align-items: stretch; flex-direction: column; } .wayfarer-travel-pause { width: 100%; } .wayfarer-action-utility-row { grid-template-columns: 1fr; } } /* ------------------------------------------------------------------ LOCATION / STORY PRESENTATION WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 ------------------------------------------------------------------ */ /* * Scene prose now belongs to Presentation Mode rather than the normal Action * page. The server-rendered scene remains in the DOM temporarily for runtime * compatibility but is not player-facing. */ #wayfarer-player-scene { display: none; } .wayfarer-action-parent-context { margin-bottom: 4px !important; font-size: 0.85rem; font-weight: 600; } /* ------------------------------------------------------------------ STORYLET ACTIONS / BRANCHES WAYFARER_STORYLET_ACTION_RUNTIME_V59 ------------------------------------------------------------------ */ .wayfarer-storylet-action-list { display: grid; gap: 10px; } .wayfarer-storylet-action-list:empty { display: none; } .wayfarer-storylet-branch-list { width: 100%; display: grid; gap: 10px; } .wayfarer-presentation-actions.wayfarer-has-storylet-branches > div:not(.wayfarer-storylet-branch-list) { display: none; } .wayfarer-storylet-branch-prompt { margin: 0 0 4px; color: #b8c0cc; font-weight: 700; } .wayfarer-storylet-branch-button { width: 100%; padding: 12px 14px; border: 1px solid #f8fafc; border-radius: 8px; background: transparent; color: #f8fafc; cursor: pointer; font: inherit; font-weight: 700; text-align: left; } .wayfarer-storylet-branch-button:hover, .wayfarer-storylet-branch-button:focus-visible { background: #f8fafc; color: #15181d; } .wayfarer-storylet-branch-button:disabled { cursor: default; opacity: 0.55; } .wayfarer-presentation-screen { position: fixed; z-index: 10000; inset: 0; overflow-y: auto; padding: clamp(24px, 6vh, 72px) 24px; background: #15181d; color: #f8fafc; } .wayfarer-presentation-screen[hidden] { display: none !important; } .wayfarer-presentation-inner { width: min(1040px, 100%); min-height: calc(100vh - clamp(48px, 12vh, 144px)); margin: 0 auto; display: flex; flex-direction: column; justify-content: center; } .wayfarer-presentation-media { width: 100%; margin: 0 0 26px; overflow: hidden; border-radius: 12px; background: #0d0f12; } .wayfarer-presentation-media img { display: block; width: 100%; max-height: 52vh; object-fit: cover; } .wayfarer-presentation-copy { width: min(760px, 100%); margin: 0 auto; } .wayfarer-presentation-eyebrow { margin: 0 0 8px; color: #b8c0cc; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .wayfarer-presentation-copy h1 { margin: 0 0 18px; color: inherit; font-size: clamp(2rem, 5vw, 3.8rem); line-height: 1.05; } .wayfarer-presentation-description { font-size: clamp(1rem, 2vw, 1.22rem); line-height: 1.7; } .wayfarer-presentation-actions { width: min(760px, 100%); margin: 32px auto 0; display: flex; justify-content: flex-end; } .wayfarer-presentation-continue { min-width: 150px; padding: 11px 18px; border: 1px solid #f8fafc; border-radius: 8px; background: #f8fafc; color: #15181d; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-presentation-continue:hover { background: #e8edf3; } .wayfarer-presentation-continue:focus-visible { outline: 3px solid #ffffff; outline-offset: 4px; } .wayfarer-presentation-key-hint { margin: 10px 0 0; color: #9ea8b6; font-size: 0.78rem; text-align: right; } body.wayfarer-runtime-presentation { overflow: hidden; } @media (max-width: 620px) { .wayfarer-presentation-screen { padding: 20px 14px; } .wayfarer-presentation-inner { min-height: calc(100vh - 40px); } .wayfarer-presentation-actions { justify-content: stretch; } .wayfarer-presentation-continue { width: 100%; } } /* ------------------------------------------------------------------ COMBINED AUTHOR STATE + REGION WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 ------------------------------------------------------------------ */ .wayfarer-author-state-region { margin-top: 20px; padding-top: 14px; border-top: 1px solid #e4e7ec; } .wayfarer-author-state-region > :first-child { margin-top: 0; } /* ------------------------------------------------------------------ TERMINAL GAME ENDING WAYFARER_GAME_ENDING_SCREEN_V45 ------------------------------------------------------------------ */ .wayfarer-ending-screen { position: fixed; z-index: 11000; inset: 0; overflow-y: auto; padding: clamp(24px, 6vh, 72px) 24px; background: #11151a; color: #f8fafc; } .wayfarer-ending-inner { width: min(1040px, 100%); min-height: calc(100vh - clamp(48px, 12vh, 144px)); margin: 0 auto; display: flex; flex-direction: column; justify-content: center; } .wayfarer-ending-media { width: 100%; margin: 0 0 28px; overflow: hidden; border-radius: 12px; background: #080a0d; } .wayfarer-ending-media img { display: block; width: 100%; max-height: 52vh; object-fit: cover; } .wayfarer-ending-copy { width: min(760px, 100%); margin: 0 auto; } .wayfarer-ending-eyebrow { margin: 0 0 8px; color: #aeb8c5; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .wayfarer-ending-copy h1 { margin: 0 0 18px; color: inherit; font-size: clamp(2rem, 5vw, 3.8rem); line-height: 1.05; } .wayfarer-ending-text { font-size: clamp(1rem, 2vw, 1.22rem); line-height: 1.7; } body.wayfarer-game-ended { overflow: hidden; } /* ------------------------------------------------------------------ PRESENTATION TRANSITION + ENDING RESTART WAYFARER_PRESENTATION_TRANSITION_V63 WAYFARER_ENDING_RESTART_V63 ------------------------------------------------------------------ */ body.wayfarer-presentation-transitioning::after { content: ""; position: fixed; z-index: 10500; inset: 0; background: #15181d; pointer-events: all; } .wayfarer-ending-actions { margin-top: 30px; display: flex; justify-content: flex-end; } .wayfarer-ending-restart { min-width: 150px; padding: 11px 18px; border: 1px solid #f8fafc; border-radius: 8px; background: #f8fafc; color: #15181d; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-ending-restart:hover { background: #e8edf3; } .wayfarer-ending-restart:focus-visible { outline: 3px solid #ffffff; outline-offset: 4px; } .wayfarer-ending-restart:disabled { cursor: default; opacity: 0.6; } @media (max-width: 620px) { .wayfarer-ending-actions { justify-content: stretch; } .wayfarer-ending-restart { width: 100%; } } /* ------------------------------------------------------------------ PERSISTENT PLAYER HUD WAYFARER_PLAYER_HUD_V64 ------------------------------------------------------------------ */ .wayfarer-player-pane { display: flex; flex-direction: column; } .wayfarer-player-hud { flex: 0 0 auto; margin-top: auto; padding-top: 18px; } .wayfarer-player-hud[hidden] { display: none !important; } .wayfarer-hud-components { display: grid; grid-template-columns: repeat( auto-fit, minmax( min(260px, 100%), 1fr ) ); gap: 12px 18px; padding: 14px 16px; border: 1px solid #d8dbe2; border-radius: 10px; background: #f8fafc; } .wayfarer-hud-progress-component { min-width: 0; } .wayfarer-hud-progress-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 7px; } .wayfarer-hud-label { min-width: 0; overflow: hidden; font-size: 0.88rem; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } .wayfarer-hud-value { flex: 0 0 auto; color: #667085; font-size: 0.82rem; font-variant-numeric: tabular-nums; } .wayfarer-hud-progress-track { height: 10px; overflow: hidden; border-radius: 999px; background: #dfe4ea; } .wayfarer-hud-progress-fill { height: 100%; min-width: 0; border-radius: inherit; background: #2f5ea8; transition: width 180ms ease; } @media (max-width: 620px) { .wayfarer-player-hud { padding-top: 14px; } .wayfarer-hud-components { grid-template-columns: 1fr; gap: 12px; padding: 12px 13px; } } /* ------------------------------------------------------------------ REGION HUD AUTHORING WAYFARER_REGION_HUD_EDITOR_V64 ------------------------------------------------------------------ */ .builder-region-hud { margin: 0; } .builder-region-hud > h3 { margin-top: 0; } .builder-region-hud-component { margin: 14px 0; padding: 14px 16px; border: 1px solid #d8dbe2; border-radius: 8px; } .builder-region-hud-component legend { padding: 0 6px; font-weight: 700; } .builder-region-hud-component select, .builder-region-hud-component input[type="text"] { width: min(560px, 100%); max-width: 100%; } /* ------------------------------------------------------------------ ONE-TIME EFFECT PLAYER FEEDBACK WAYFARER_EFFECT_FEEDBACK_V70 ------------------------------------------------------------------ */ .wayfarer-presentation-changes { margin-top: 18px; padding: 12px 14px; border: 1px solid rgba(31, 41, 55, 0.16); border-radius: 8px; background: rgba(255, 255, 255, 0.68); } .wayfarer-presentation-changes-heading { margin: 0 0 7px; font-size: 0.78rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.68; } .wayfarer-presentation-changes-list { display: grid; gap: 5px; margin: 0; padding: 0; list-style: none; } .wayfarer-presentation-change { margin: 0; font-weight: 700; line-height: 1.35; } .wayfarer-presentation-change.is-gain::before, .wayfarer-presentation-change.is-loss::before { display: inline-block; width: 0; overflow: hidden; content: ''; } ================================================================================ FILE: _wayfarer_backups/v94-20260930-170953/play/assets/play.js ================================================================================ /* |-------------------------------------------------------------------------- | PLAYER RUNTIME CONTROLLER | WAYFARER_ACTION_SCREEN_V39 | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | Runtime modes currently exposed to the player: | | presentation -> Action -> running navigation -> presentation/Action | | Presentation is persisted by PHP. Automatic tick advancement cannot continue | while a presentation is active. | */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; if (!config) { return; } const body = document.body; const tickValue = document.getElementById( 'wayfarer-tick-value' ); const statusValue = document.getElementById( 'wayfarer-tick-status' ); const message = document.getElementById( 'wayfarer-tick-message' ); const onceButton = document.getElementById( 'wayfarer-tick-once' ); const autoButton = document.getElementById( 'wayfarer-tick-auto' ); const authorPauseButton = document.getElementById( 'wayfarer-tick-pause' ); const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); /* WAYFARER_PLAYER_HUD_V64 */ const playerHud = document.querySelector( '[data-wayfarer-hud]' ); /* WAYFARER_TRAVEL_TIME_HUD_V61 */ const travelTime = document.querySelector( '[data-wayfarer-travel-time]' ); const travelTimeSeparator = document.querySelector( '[data-wayfarer-travel-time-separator]' ); const travelNext = document.querySelector( '[data-wayfarer-travel-next]' ); const continueTravelButton = document.querySelector( '[data-wayfarer-continue-travel]' ); const playerPauseButton = document.querySelector( '[data-wayfarer-pause-travel]' ); /* WAYFARER_PLAYER_TURN_AROUND_UI_V72 */ const turnAroundButton = document.querySelector( '[data-wayfarer-turn-around]' ); /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Action Mode owns one physical card. Inventory, Status, and future * utility/context views temporarily replace the normal action heading/list * inside that card without becoming persisted runtime modes. */ const actionHeading = actionScreen ? actionScreen.querySelector( '.wayfarer-action-heading' ) : null; const actionList = actionScreen ? actionScreen.querySelector( '[data-wayfarer-action-list]' ) : null; const actionSubviewPanels = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ) : []; const actionViewOpeners = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-open-action-view]' ) ) : []; const actionViewClosers = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-close-action-view]' ) ) : []; /* * Persisted narrative presentation. */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); const presentationContinueButton = presentationScreen ? presentationScreen.querySelector( '[data-wayfarer-presentation-continue]' ) : null; let running = false; let requestInFlight = false; let presentationRequestInFlight = false; let storyletRequestInFlight = false; let timeoutId = null; let reloadWhenIdle = false; /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ let requestedTravelDirection = Number( config.initialDirection ) < 0 ? -1 : 1; let presentationActive = Boolean( presentationScreen && !presentationScreen.hidden ); function setMessage( text, isError = false ) { if (!message) { return; } message.textContent = text; message.style.color = isError ? 'red' : ''; } function emitModeEvent() { let mode = 'paused'; if (presentationActive) { mode = 'presentation'; } else if (running) { mode = 'running'; } window.dispatchEvent( new CustomEvent( 'wayfarer:runtime-mode', { detail: { mode: mode } } ) ); /* * Keep the v39 event names available for existing development hooks. */ window.dispatchEvent( new CustomEvent( running ? 'wayfarer:runtime-running' : 'wayfarer:runtime-paused', { detail: { mode: mode } } ) ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Generic in-card view switcher. Future Map / Shop / other views can use * the same data-wayfarer-open-action-view + data-wayfarer-action-view * contract without adding another page-level layout system. */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Resolve available views at the moment navigation occurs instead of * relying on the V48 page-load snapshot. This supports nested Entity * detail screens and future context views added to the same Action card. */ function setActionView(viewName = 'actions') { if (!actionScreen) { return; } const requested = String( viewName || 'actions' ); const showActions = requested === 'actions'; const panels = Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ); let matched = showActions; panels.forEach( function (panel) { const active = panel.dataset .wayfarerActionView === requested; panel.hidden = !active; if (active) { matched = true; } } ); /* * Fail safely if a malformed/removed view name is requested. */ if (!matched) { panels.forEach( function (panel) { panel.hidden = true; } ); if (actionHeading) { actionHeading.hidden = false; } if (actionList) { actionList.hidden = false; } return; } if (actionHeading) { actionHeading.hidden = !showActions; } if (actionList) { actionList.hidden = !showActions; } } function renderControls() { if (statusValue) { statusValue.textContent = presentationActive ? 'Presentation' : ( running ? 'Running' : 'Paused' ); } if (onceButton) { onceButton.disabled = running || requestInFlight || presentationActive; } if (autoButton) { autoButton.disabled = running || requestInFlight || presentationActive; } if (authorPauseButton) { authorPauseButton.disabled = !running; } if (continueTravelButton) { continueTravelButton.disabled = running || requestInFlight || presentationActive; } document .querySelectorAll( '[data-wayfarer-storylet-action-id], ' + '[data-wayfarer-storylet-branch-id]' ) .forEach( function (button) { button.disabled = requestInFlight || presentationRequestInFlight || storyletRequestInFlight || ( button.hasAttribute( 'data-wayfarer-storylet-action-id' ) && (running || presentationActive) ); } ); if (playerPauseButton) { playerPauseButton.disabled = !running; } /* WAYFARER_PLAYER_TURN_AROUND_UI_V72 */ if (turnAroundButton) { turnAroundButton.disabled = running || requestInFlight || presentationActive; } if (actionScreen) { actionScreen.hidden = running || presentationActive; } if (travelControls) { travelControls.hidden = !running || presentationActive; } /* WAYFARER_PLAYER_HUD_V64 */ if (playerHud) { playerHud.hidden = presentationActive; } if (presentationScreen) { presentationScreen.hidden = !presentationActive; presentationScreen.setAttribute( 'aria-hidden', presentationActive ? 'false' : 'true' ); } body.classList.toggle( 'wayfarer-runtime-running', running ); body.classList.toggle( 'wayfarer-runtime-paused', !running && !presentationActive ); body.classList.toggle( 'wayfarer-runtime-presentation', presentationActive ); } /* * WAYFARER_LIVE_ACTION_CONTEXT_V46_1 * * Action Mode must reflect the current runtime position, not the page's * original server-rendered context. */ function renderPlayerContext(context) { if ( !context || typeof context !== 'object' ) { return; } const heading = document.querySelector( '[data-wayfarer-action-context-name]' ); const parent = document.querySelector( '[data-wayfarer-action-parent-context]' ); if (heading) { const name = String( context.context_name || 'Actions' ).trim(); heading.textContent = name || 'Actions'; } if (parent) { const parentText = String( context.parent_context || '' ).trim(); parent.textContent = parentText; parent.hidden = parentText === ''; } } /* WAYFARER_TRAVEL_TIME_HUD_RENDER_V61 */ function renderTravelStatus(travel) { if (!travel || typeof travel !== 'object') { return; } const timeLabel = String(travel.time_label || '').trim(); if (travelTime) { travelTime.textContent = timeLabel; travelTime.hidden = timeLabel === ''; } if (travelTimeSeparator) { travelTimeSeparator.hidden = timeLabel === ''; } const nextLabel = String(travel.next_label || '').trim(); if (travelNext) { travelNext.textContent = nextLabel === '' ? '' : 'Next: ' + nextLabel; travelNext.hidden = nextLabel === ''; } } function displayRuntimeValue(value) { if (typeof value === 'boolean') { return value ? 'True' : 'False'; } if ( value === null || value === undefined ) { return 'null'; } if (typeof value === 'object') { return JSON.stringify( value ); } return String( value ); } /* * WAYFARER_STORYLET_ACTIONS_LIVE_V59 * * Tick/presentation APIs return the currently eligible root Manual * Storylets so Action Mode does not depend on the page-load snapshot. */ function renderStoryletActions(actions) { if (!actionScreen) { return; } const host = actionScreen.querySelector( '[data-wayfarer-storylet-actions]' ); if (!host) { return; } const normalized = Array.isArray(actions) ? actions : []; host.innerHTML = ''; normalized.forEach( function (action) { const id = Number( action && action.id ? action.id : 0 ); if (id <= 0) { return; } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-action-button'; button.dataset.wayfarerStoryletActionId = String(id); button.textContent = String( action.label || action.name || 'Storylet' ); host.appendChild( button ); } ); const empty = actionScreen.querySelector( '[data-wayfarer-action-empty]' ); if (empty) { const canTravel = actionScreen.dataset.wayfarerCanTravel === '1'; empty.hidden = canTravel || host.children.length > 0; } renderControls(); } /* * WAYFARER_PLAYER_HUD_V64 * * HUD definitions remain Region-authored/server-rendered. Tick responses * already carry canonical state, so progress bars can update without adding * another API payload. */ function renderHudState(state) { if ( !playerHud || !state || typeof state !== 'object' ) { return; } playerHud .querySelectorAll( '[data-wayfarer-hud-component]' ) .forEach( function (component) { if ( component.dataset .wayfarerHudType !== 'progress_bar' ) { return; } const scope = String( component.dataset .wayfarerHudScope || '' ); const sourceId = String( component.dataset .wayfarerHudSourceId || '' ); const regionId = String( component.dataset .wayfarerHudRegionId || '' ); let traits = null; if ( scope === 'game' ) { traits = state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : null; } else if ( scope === 'region' ) { traits = state.regions && state.regions[regionId] && state.regions[regionId].traits && typeof state.regions[regionId].traits === 'object' ? state.regions[regionId].traits : null; } if ( !traits || !Object.prototype .hasOwnProperty .call( traits, sourceId ) ) { return; } const value = Number( traits[sourceId] ); const min = Number( component.dataset .wayfarerHudMin ); const max = Number( component.dataset .wayfarerHudMax ); if ( !Number.isFinite(value) || !Number.isFinite(min) || !Number.isFinite(max) || max <= min ) { return; } const percent = Math.max( 0, Math.min( 100, ( (value - min) / (max - min) ) * 100 ) ); const fill = component.querySelector( '[data-wayfarer-hud-fill]' ); if (fill) { fill.style.width = percent + '%'; } const meter = component.querySelector( '[data-wayfarer-hud-meter]' ); if (meter) { meter.setAttribute( 'aria-valuenow', String(value) ); } const valueNode = component.querySelector( '[data-wayfarer-hud-value]' ); if (valueNode) { valueNode.textContent = String(value); } } ); } function renderRuntimeState(state) { if ( !state || typeof state !== 'object' ) { return; } renderHudState( state ); if ( state.navigation && state.navigation.position !== undefined ) { const position = String( state.navigation.position ); const authorPosition = document.getElementById( 'wayfarer-author-position' ); if (authorPosition) { authorPosition.textContent = position; } } const gameTraits = state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : {}; document .querySelectorAll( '[data-wayfarer-game-trait-value]' ) .forEach( function (cell) { const traitId = cell.dataset .wayfarerGameTraitValue; if ( Object.prototype .hasOwnProperty .call( gameTraits, traitId ) ) { cell.textContent = displayRuntimeValue( gameTraits[traitId] ); } } ); document .querySelectorAll( '[data-wayfarer-region-trait-value]' ) .forEach( function (cell) { const regionId = cell.dataset .wayfarerRegionId; const traitId = cell.dataset .wayfarerRegionTraitValue; const traits = state.regions && state.regions[regionId] && state.regions[regionId].traits && typeof state.regions[regionId].traits === 'object' ? state.regions[regionId].traits : null; if ( traits && Object.prototype .hasOwnProperty .call( traits, traitId ) ) { cell.textContent = displayRuntimeValue( traits[traitId] ); } } ); const debug = document.getElementById( 'wayfarer-runtime-debug' ); if (debug) { debug.textContent = JSON.stringify( state, null, 2 ); } } async function requestTick() { if ( requestInFlight || presentationActive ) { return null; } requestInFlight = true; renderControls(); try { const response = await fetch( config.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ travel_direction: requestedTravelDirection }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Tick endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Tick request failed.' ); } if (tickValue) { tickValue.textContent = String( data.tick ); } renderRuntimeState( data.state ); /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ if ( data.state && data.state.navigation && Object.prototype .hasOwnProperty .call( data.state.navigation, 'direction' ) ) { requestedTravelDirection = Number( data.state.navigation.direction ) < 0 ? -1 : 1; } renderPlayerContext( data.context ); renderTravelStatus( data.travel ); renderStoryletActions( data.actions ); setMessage( 'Tick ' + data.tick + ' saved.' ); if (data.pause === true) { /* * Suppress Action Mode during the brief interval before reload. * The server-rendered presentation will exist after reload. */ presentationActive = true; pauseRuntime( data.pause_reason || 'Travel interrupted.', true ); } return data; } catch (error) { pauseRuntime( error instanceof Error ? error.message : 'Tick request failed.' ); setMessage( error instanceof Error ? error.message : 'Tick request failed.', true ); return null; } finally { requestInFlight = false; renderControls(); if (reloadWhenIdle) { reloadWhenIdle = false; window.location.reload(); } } } async function runAutomaticTick() { if ( !running || presentationActive ) { return; } const result = await requestTick(); if ( !running || presentationActive || result === null ) { return; } timeoutId = window.setTimeout( runAutomaticTick, Number( config.intervalMs ) || 1500 ); } function startRuntime() { if ( running || requestInFlight || presentationActive ) { return; } /* * Travel always leaves inspection/context views and returns the card to * its normal Action view for the next pause. */ setActionView( 'actions' ); running = true; setMessage( 'Automatic advancement started.' ); renderControls(); emitModeEvent(); runAutomaticTick(); } /* * WAYFARER_PRESENTATION_TRANSITION_V63 * * Cover the current DOM before a server-rendered Presentation replaces it. * This prevents the previous Location/Storylet copy from being painted for * a frame during the navigation boundary. */ function beginPresentationTransition() { body.classList.add( 'wayfarer-presentation-transitioning' ); } function pauseRuntime( reason = 'Automatic advancement paused.', reloadForPresentation = false ) { const wasRunning = running; running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } setMessage( reason ); if (wasRunning) { setActionView( 'actions' ); } renderControls(); if (wasRunning) { emitModeEvent(); } if (reloadForPresentation) { /* WAYFARER_PRESENTATION_TRANSITION_V63 */ beginPresentationTransition(); if (requestInFlight) { reloadWhenIdle = true; } else { window.location.reload(); } } } /* * WAYFARER_STORYLET_ACTION_RUNTIME_V59 */ async function requestStorylet( storyletId, entryMode = 'action', parentStoryletId = 0 ) { if ( storyletRequestInFlight || requestInFlight || presentationRequestInFlight ) { return; } storyletId = Number(storyletId || 0); parentStoryletId = Number(parentStoryletId || 0); if (storyletId <= 0) { return; } storyletRequestInFlight = true; renderControls(); try { const response = await fetch( config.storyletEndpoint || 'api/storylet.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, storylet_id: storyletId, entry_mode: entryMode, parent_storylet_id: parentStoryletId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Storylet endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not enter Storylet.' ); } /* * Storylet presentation/Branch markup is server-rendered. Reload * only after the runtime transaction succeeds, so Effects cannot be * repeated by a failed or duplicate browser transition. */ /* WAYFARER_PRESENTATION_TRANSITION_V63 */ beginPresentationTransition(); window.location.reload(); } catch (error) { window.alert( error instanceof Error ? error.message : 'Could not enter Storylet.' ); } finally { storyletRequestInFlight = false; renderControls(); } } async function dismissPresentation() { if ( !presentationActive || !presentationScreen || presentationRequestInFlight ) { return; } presentationRequestInFlight = true; if (presentationContinueButton) { presentationContinueButton.disabled = true; } const presentationType = presentationScreen.dataset .wayfarerPresentationType || ''; const presentationId = Number( presentationScreen.dataset .wayfarerPresentationId || 0 ); try { const response = await fetch( config.presentationEndpoint || 'api/presentation.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, type: presentationType, id: presentationId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Presentation endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not continue.' ); } presentationActive = false; setActionView( 'actions' ); renderRuntimeState( data.state ); renderPlayerContext( data.context ); renderStoryletActions( data.actions ); renderControls(); emitModeEvent(); /* * Place keyboard focus on the first available Action so the * transition works cleanly for keyboard players too. */ const firstAction = actionScreen ? actionScreen.querySelector( 'button:not([disabled]), a[href]' ) : null; if ( firstAction && typeof firstAction.focus === 'function' ) { firstAction.focus(); } } catch (error) { if (presentationContinueButton) { presentationContinueButton.disabled = false; } window.alert( error instanceof Error ? error.message : 'Could not continue.' ); } finally { presentationRequestInFlight = false; /* WAYFARER_PRESENTATION_CONTROL_RESET_V62 * * dismissPresentation() previously refreshed Action controls while * presentationRequestInFlight was still true. Newly-rendered Manual * Storylet buttons were therefore left disabled after a Location or * Storylet presentation was dismissed. Re-render once the request * flag has actually been cleared. */ renderControls(); if ( presentationContinueButton && presentationActive ) { presentationContinueButton.disabled = false; } } } if (onceButton) { onceButton.addEventListener( 'click', async function () { if ( running || presentationActive ) { return; } setMessage(''); await requestTick(); } ); } if (autoButton) { autoButton.addEventListener( 'click', startRuntime ); } if (authorPauseButton) { authorPauseButton.addEventListener( 'click', function () { pauseRuntime( 'Automatic advancement paused.' ); } ); } /* WAYFARER_PLAYER_TURN_AROUND_UI_V72 */ if (turnAroundButton) { turnAroundButton.addEventListener( 'click', function () { requestedTravelDirection = requestedTravelDirection < 0 ? 1 : -1; startRuntime(); } ); } if (continueTravelButton) { continueTravelButton.addEventListener( 'click', startRuntime ); } if (playerPauseButton) { playerPauseButton.addEventListener( 'click', function () { /* * WAYFARER_SMOOTH_MANUAL_PAUSE_V46_2 * * v46.1 keeps Action context current in-place, so manual Pause * no longer needs a full page reload. */ pauseRuntime( 'Travel paused.' ); } ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Ensure V50 detail panels physically belong to the Action card. This is * harmless when the PHP view already placed them correctly, and repairs * any earlier V50 markup that landed just outside the host. */ if (actionScreen) { document .querySelectorAll( '.wayfarer-entity-detail-view' ) .forEach( function (panel) { if ( !actionScreen.contains( panel ) ) { actionScreen.appendChild( panel ); } } ); /* * Delegate navigation from the Action card itself. Any current or * future control using the generic data attributes automatically * participates in the same router. */ actionScreen.addEventListener( 'click', function (event) { const storyletAction = event.target.closest( '[data-wayfarer-storylet-action-id]' ); if ( storyletAction && actionScreen.contains( storyletAction ) ) { if ( running || presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( storyletAction.dataset .wayfarerStoryletActionId, 'action', 0 ); return; } const opener = event.target.closest( '[data-wayfarer-open-action-view]' ); if ( opener && actionScreen.contains( opener ) ) { if ( running || presentationActive ) { return; } event.preventDefault(); setActionView( opener.dataset .wayfarerOpenActionView || 'actions' ); return; } const closer = event.target.closest( '[data-wayfarer-close-action-view]' ); if ( !closer || !actionScreen.contains( closer ) ) { return; } event.preventDefault(); const returnView = closer.dataset .wayfarerReturnActionView || 'actions'; setActionView( returnView ); let focusTarget = null; if (returnView === 'actions') { focusTarget = actionList ? actionList.querySelector( 'button:not([disabled]), a[href]' ) : null; } else { const parentPanel = actionScreen.querySelector( '[data-wayfarer-action-view="' + returnView + '"]' ); focusTarget = parentPanel ? parentPanel.querySelector( '[data-wayfarer-open-action-view], button:not([disabled]), a[href]' ) : null; } if ( focusTarget && typeof focusTarget.focus === 'function' ) { focusTarget.focus(); } } ); } /* WAYFARER_STORYLET_BRANCH_SELECTION_V59 */ document.addEventListener( 'click', function (event) { const branch = event.target.closest( '[data-wayfarer-storylet-branch-id]' ); if (!branch) { return; } if ( !presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( branch.dataset .wayfarerStoryletBranchId, 'branch', branch.dataset .wayfarerStoryletParentId ); } ); if (presentationContinueButton) { presentationContinueButton.addEventListener( 'click', dismissPresentation ); } document.addEventListener( 'keydown', function (event) { if ( !presentationActive || presentationRequestInFlight || storyletRequestInFlight || !presentationContinueButton ) { return; } const target = event.target; if ( target && target.matches && target.matches( 'input, textarea, select, button, a[href], [contenteditable="true"]' ) ) { return; } if ( event.key !== 'Enter' && event.key !== ' ' ) { return; } event.preventDefault(); dismissPresentation(); } ); window.addEventListener( 'pagehide', function () { running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } } ); window.wayfarerRuntimeController = { start: startRuntime, pause: pauseRuntime, dismissPresentation: dismissPresentation, enterStorylet: requestStorylet, isRunning: function () { return running; }, mode: function () { if (presentationActive) { return 'presentation'; } return running ? 'running' : 'paused'; } }; setActionView( 'actions' ); renderControls(); emitModeEvent(); })(); /* |-------------------------------------------------------------------------- | WAYFARER PLAY SHELL v20 |-------------------------------------------------------------------------- | | Reorganizes existing server-rendered nodes into the viewport shell. | Existing nodes are MOVED, not cloned/replaced, so the tick-control | event listeners installed above remain intact. | */ (() => { 'use strict'; if ( document.getElementById( 'wayfarer-play-shell' ) ) { return; } function exactHeading( level, text ) { const headings = document.querySelectorAll( level ); for (const heading of headings) { if ( heading.textContent .replace(/\s+/g, ' ') .trim() === text ) { return heading; } } return null; } function moveRange( start, stopExclusive, destination ) { if ( !start || !destination ) { return; } let node = start; while ( node && node !== stopExclusive ) { const next = node.nextSibling; destination.appendChild( node ); node = next; } } const body = document.body; /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * PHP decides whether Author Mode is permitted. JavaScript only shapes the * interface around that server-side decision. */ const canUseAuthorMode = body.dataset.wayfarerAuthorMode === '1'; body.classList.add( 'wayfarer-play-page' ); const shell = document.createElement( 'div' ); shell.id = 'wayfarer-play-shell'; shell.className = 'wayfarer-play-shell'; const header = document.createElement( 'header' ); header.className = 'wayfarer-play-shell-header'; const title = document.createElement( 'div' ); title.className = 'wayfarer-play-title'; const actions = document.createElement( 'div' ); actions.className = 'wayfarer-shell-actions'; const toggleAuthor = document.createElement( 'button' ); toggleAuthor.type = 'button'; toggleAuthor.className = 'wayfarer-shell-button'; if (canUseAuthorMode) { actions.appendChild( toggleAuthor ); } header.appendChild( title ); if (canUseAuthorMode) { header.appendChild( actions ); } const main = document.createElement( 'main' ); main.className = 'wayfarer-play-main'; const playerPane = document.createElement( 'section' ); playerPane.className = 'wayfarer-player-pane'; const authorPane = document.createElement( 'aside' ); authorPane.className = 'wayfarer-author-pane'; const authorHeader = document.createElement( 'div' ); authorHeader.className = 'wayfarer-author-header'; const authorToolbar = document.createElement( 'div' ); authorToolbar.className = 'wayfarer-author-toolbar'; const tabBar = document.createElement( 'div' ); tabBar.className = 'wayfarer-author-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const authorContent = document.createElement( 'div' ); authorContent.className = 'wayfarer-author-content'; authorPane.appendChild( authorHeader ); authorPane.appendChild( authorToolbar ); authorPane.appendChild( tabBar ); authorPane.appendChild( authorContent ); main.appendChild( playerPane ); if (canUseAuthorMode) { main.appendChild( authorPane ); } else { /* * Keep the player workspace full-width when Author Mode is unavailable. * No Author pane was rendered by PHP and none is attached here. */ shell.classList.add( 'author-collapsed' ); } shell.appendChild( header ); shell.appendChild( main ); body.insertBefore( shell, body.firstChild ); // ------------------------------------------------------------ // Top title/player identity // ------------------------------------------------------------ const pageTitle = Array.from( body.querySelectorAll( ':scope > h1' ) )[0]; if (pageTitle) { const playerIdentity = pageTitle.nextElementSibling; title.appendChild( pageTitle ); if ( playerIdentity && playerIdentity.tagName === 'P' ) { title.appendChild( playerIdentity ); } } // ------------------------------------------------------------ // Player content // ------------------------------------------------------------ /* * WAYFARER_PLAYER_START_SCREEN_V36 * * Before runtime state exists, the start screen owns the full player * workspace and Author Mode is intentionally hidden. */ const startScreen = document.querySelector( '[data-wayfarer-start-screen]' ); if (startScreen) { shell.classList.add( 'is-starting' ); playerPane.appendChild( startScreen ); } const scene = document.getElementById( 'wayfarer-player-scene' ); const gameStateHeading = exactHeading( 'h2', 'Game State' ) || exactHeading( 'h2', 'Game Not Initialized' ); if (gameStateHeading) { const meta = document.createElement( 'div' ); meta.className = 'wayfarer-player-meta'; /* * Move from Game State through the node immediately before the * player scene. This includes initialization messaging/forms but * stops before the actual scene. */ moveRange( gameStateHeading, scene, meta ); playerPane.appendChild( meta ); } if (scene) { playerPane.appendChild( scene ); } /* * WAYFARER_ACTION_SCREEN_V39 * * Action Mode is player-facing and exists independently of Author Mode. */ const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); if (actionScreen) { playerPane.appendChild( actionScreen ); } /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); if (presentationScreen) { playerPane.appendChild( presentationScreen ); } const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); if (travelControls) { playerPane.appendChild( travelControls ); } /* WAYFARER_PLAYER_HUD_V64 */ const persistentHud = document.querySelector( '[data-wayfarer-hud]' ); if (persistentHud) { playerPane.appendChild( persistentHud ); } // ------------------------------------------------------------ // Author header // ------------------------------------------------------------ const authorHeading = exactHeading( 'h2', 'Author Mode' ); if (authorHeading) { const builderLinkBlock = authorHeading.nextElementSibling; authorHeader.appendChild( authorHeading ); if ( builderLinkBlock && builderLinkBlock.tagName === 'P' ) { authorHeader.appendChild( builderLinkBlock ); } } // ------------------------------------------------------------ // Runtime controls // ------------------------------------------------------------ const tickPanel = document.getElementById( 'wayfarer-tick-test' ); if (tickPanel) { authorToolbar.appendChild( tickPanel ); } // ------------------------------------------------------------ // Author tabs // ------------------------------------------------------------ /* WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 */ const panelDefinitions = [ { key: 'state', label: 'State' }, { key: 'debug', label: 'Debug' } ]; function activateTab(key) { document .querySelectorAll( '.wayfarer-author-tab' ) .forEach( function (button) { const active = button.dataset .wayfarerAuthorTab === key; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); authorContent .querySelectorAll( '.wayfarer-author-tab-panel' ) .forEach( function (panel) { panel.classList.toggle( 'is-active', panel.dataset .wayfarerAuthorPanel === key ); } ); try { window.localStorage.setItem( 'wayfarerAuthorTab', key ); } catch (error) { // Storage is optional. } } for (const definition of panelDefinitions) { const panel = document.querySelector( '[data-wayfarer-author-panel="' + definition.key + '"]' ); if (!panel) { continue; } authorContent.appendChild( panel ); const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-author-tab'; button.dataset.wayfarerAuthorTab = definition.key; button.setAttribute( 'role', 'tab' ); button.textContent = definition.label; button.addEventListener( 'click', function () { activateTab( definition.key ); } ); tabBar.appendChild( button ); } let initialTab = 'state'; try { let savedTab = window.localStorage.getItem( 'wayfarerAuthorTab' ); if ( savedTab === 'region' ) { savedTab = 'state'; window.localStorage.setItem( 'wayfarerAuthorTab', 'state' ); } if ( savedTab && authorContent.querySelector( '[data-wayfarer-author-panel="' + savedTab + '"]' ) ) { initialTab = savedTab; } } catch (error) { // Storage is optional. } activateTab( initialTab ); // ------------------------------------------------------------ // Hide / Show Author Mode // WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 // ------------------------------------------------------------ if (canUseAuthorMode) { function renderAuthorToggle() { const collapsed = shell.classList.contains( 'author-collapsed' ); toggleAuthor.textContent = collapsed ? 'Show Author' : 'Hide Author'; toggleAuthor.setAttribute( 'aria-expanded', collapsed ? 'false' : 'true' ); } toggleAuthor.addEventListener( 'click', function () { shell.classList.toggle( 'author-collapsed' ); try { window.localStorage.setItem( 'wayfarerAuthorCollapsed', shell.classList.contains( 'author-collapsed' ) ? '1' : '0' ); } catch (error) { // Storage is optional. } renderAuthorToggle(); } ); try { if ( window.localStorage.getItem( 'wayfarerAuthorCollapsed' ) === '1' ) { shell.classList.add( 'author-collapsed' ); } } catch (error) { // Storage is optional. } renderAuthorToggle(); } else { /* * Player role is not merely a hidden Author interface. Keep the shell * permanently in player-only layout. */ shell.classList.add( 'author-collapsed' ); } // ------------------------------------------------------------ // Remove otherwise-empty top-level HRs left by the old document flow. // ------------------------------------------------------------ body .querySelectorAll( ':scope > hr' ) .forEach( function (rule) { rule.remove(); } ); })(); /* |-------------------------------------------------------------------------- | ENDING RESTART | WAYFARER_ENDING_RESTART_V63 |-------------------------------------------------------------------------- */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; const restartButton = document.querySelector( '[data-wayfarer-restart-game]' ); if ( !config || !restartButton ) { return; } let requestInFlight = false; restartButton.addEventListener( 'click', async function () { if (requestInFlight) { return; } if ( !window.confirm( 'Start this game over? Your current playthrough progress will be erased.' ) ) { return; } requestInFlight = true; restartButton.disabled = true; const originalText = restartButton.textContent; restartButton.textContent = 'Starting over…'; try { const response = await fetch( config.restartEndpoint || 'api/restart.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Restart endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not restart the game.' ); } document.body.classList.add( 'wayfarer-presentation-transitioning' ); window.location.href = 'index.php'; } catch (error) { requestInFlight = false; restartButton.disabled = false; restartButton.textContent = originalText; window.alert( error instanceof Error ? error.message : 'Could not restart the game.' ); } } ); })(); ================================================================================ FILE: _wayfarer_backups/v94-20260930-170953/play/includes/hud.php ================================================================================ prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $hudRegionStmt->execute([ $hudRegionId, (int)$gameId, ]); $hudRegionRaw = $hudRegionStmt->fetchColumn(); $hudRegionConfig = $hudRegionRaw ? json_decode( (string)$hudRegionRaw, true ) : []; if (!is_array($hudRegionConfig)) { $hudRegionConfig = []; } $hudConfig = isset($hudRegionConfig['hud']) && is_array($hudRegionConfig['hud']) ? $hudRegionConfig['hud'] : []; if (empty($hudConfig['enabled'])) { return; } $hudComponents = isset($hudConfig['components']) && is_array($hudConfig['components']) ? array_values( $hudConfig['components'] ) : []; if (!$hudComponents) { return; } /* * Definition catalogs supply labels/min/max while runtime state supplies values. */ $hudGameDefinitions = []; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if ( is_array($trait) && !empty($trait['id']) ) { $hudGameDefinitions[ (string)$trait['id'] ] = $trait; } } $hudRegionDefinitions = []; foreach ( ( isset($hudRegionConfig['traits']) && is_array($hudRegionConfig['traits']) ? $hudRegionConfig['traits'] : [] ) as $trait ) { if ( is_array($trait) && !empty($trait['id']) ) { $hudRegionDefinitions[ (string)$trait['id'] ] = $trait; } } $hudRendered = []; foreach ($hudComponents as $component) { if (!is_array($component)) { continue; } $type = strtolower( trim( (string)( $component['type'] ?? '' ) ) ); if ($type !== 'progress_bar') { continue; } $scope = strtolower( trim( (string)( $component['source_scope'] ?? '' ) ) ); $sourceId = trim( (string)( $component['source_id'] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { continue; } $definition = $scope === 'game' ? ( $hudGameDefinitions[ $sourceId ] ?? null ) : ( $hudRegionDefinitions[ $sourceId ] ?? null ); if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) ) { continue; } $min = (float)$definition['min']; $max = (float)$definition['max']; if ($max <= $min) { continue; } if ($scope === 'game') { $runtimeTraits = isset($state['game']['traits']) && is_array( $state['game']['traits'] ) ? $state['game']['traits'] : []; } else { $runtimeTraits = isset( $state['regions'][ (string)$hudRegionId ]['traits'] ) && is_array( $state['regions'][ (string)$hudRegionId ]['traits'] ) ? $state['regions'][ (string)$hudRegionId ]['traits'] : []; } $value = $runtimeTraits[ $sourceId ] ?? ( $definition['initial'] ?? $min ); if (!is_numeric($value)) { continue; } $value = (float)$value; $percent = (($value - $min) / ($max - $min)) * 100; $percent = max( 0, min( 100, $percent ) ); $label = trim( (string)( $component['label'] ?? '' ) ); if ($label === '') { $label = trim( (string)( $definition['name'] ?? $sourceId ) ); } $hudRendered[] = [ 'id' => (string)( $component['id'] ?? '' ), 'scope' => $scope, 'source_id' => $sourceId, 'label' => $label !== '' ? $label : $sourceId, 'min' => $min, 'max' => $max, 'value' => $value, 'percent' => $percent, 'show_value' => !empty( $component['show_value'] ), ]; } if (!$hudRendered) { return; } $hudPresentationActive = !empty( $state['runtime']['presentation'] ); $hudEndingActive = !empty( $state['runtime']['ending'] ); ?>
>
================================================================================ FILE: _wayfarer_backups/v95-20260930-180850/includes/builder/game-hud.php ================================================================================

Travel HUD

Choose live values to show directly below the Travel status panel. HUD items are hidden on the Action screen so Storylets keep the full Action card. Leave every Region unchecked to show an item in all Regions.

$component): ?>
HUD Item

>

Regions Leave all unchecked for every Region.

No direct numeric Trait or Item Quantity sources are currently available.

================================================================================ FILE: _wayfarer_backups/v95-20260930-180850/includes/gameplay/hud.php ================================================================================ $target) { if (!is_array($target)) { continue; } if ( strtolower(trim((string)($target['data_type'] ?? ''))) !== 'number' || strtolower(trim((string)($target['target_type'] ?? ''))) !== 'trait' ) { continue; } $scope = trim((string)($target['target_scope'] ?? $target['scope'] ?? '')); $targetId = trim((string)($target['target_id'] ?? '')); /* * V94 is deliberately direct-state only. Party aggregation is a useful * future source, but it needs explicit average/min/max semantics first. */ if ( $targetId === '' || !( $scope === 'game' || preg_match('/^(region|location|entity):[1-9][0-9]*$/', $scope) === 1 ) ) { continue; } $label = trim((string)($target['label'] ?? $key)); $semantic = trim((string)($target['semantic_type'] ?? 'trait')); $hudLabel = $label; if (strpos($label, ' → ') !== false) { $parts = explode(' → ', $label); if ($semantic === 'item_quantity' && count($parts) >= 2) { $hudLabel = trim((string)$parts[count($parts) - 2]); } else { $hudLabel = trim((string)end($parts)); } } $min = isset($target['min']) && is_numeric($target['min']) ? (float)$target['min'] : null; $max = isset($target['max']) && is_numeric($target['max']) ? (float)$target['max'] : null; $sources[(string)$key] = [ 'key' => (string)$key, 'scope' => $scope, 'target_id' => $targetId, 'label' => $label !== '' ? $label : (string)$key, 'hud_label' => $hudLabel !== '' ? $hudLabel : ($label !== '' ? $label : (string)$key), 'min' => $min, 'max' => $max, 'bounded' => $min !== null && $max !== null && $max > $min, 'semantic_type' => $semantic, ]; } uasort( $sources, static function (array $a, array $b): int { return strcasecmp((string)$a['label'], (string)$b['label']); } ); return $sources; } function gameplayHudLegacyRegionComponents( PDO $pdo, int $gameId ): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? ORDER BY id " ); $stmt->execute([$gameId]); $merged = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $regionId = (int)($row['id'] ?? 0); if ($regionId <= 0) { continue; } $config = gameplayRuntimeStateDecodeConfig($row['config'] ?? null); $hud = isset($config['hud']) && is_array($config['hud']) ? $config['hud'] : []; if (empty($hud['enabled']) || empty($hud['components']) || !is_array($hud['components'])) { continue; } foreach ($hud['components'] as $component) { if (!is_array($component)) { continue; } $legacyScope = trim((string)($component['source_scope'] ?? '')); $sourceId = trim((string)($component['source_id'] ?? '')); if ($sourceId === '') { continue; } $scope = $legacyScope === 'region' ? 'region:' . $regionId : $legacyScope; if ($scope !== 'game' && preg_match('/^region:[1-9][0-9]*$/', $scope) !== 1) { continue; } $sourceKey = $scope . ':trait:' . $sourceId; $label = trim((string)($component['label'] ?? '')); $type = strtolower(trim((string)($component['type'] ?? 'progress_bar'))); if (!in_array($type, ['number', 'progress_bar'], true)) { $type = 'progress_bar'; } $mergeKey = implode('|', [ $type, $sourceKey, $label, !empty($component['show_value']) ? '1' : '0', ]); if (!isset($merged[$mergeKey])) { $merged[$mergeKey] = [ 'id' => trim((string)($component['id'] ?? '')), 'type' => $type, 'source' => $sourceKey, 'label' => $label, 'show_value' => !empty($component['show_value']), 'regions' => [], ]; } $merged[$mergeKey]['regions'][] = $regionId; $merged[$mergeKey]['regions'] = array_values(array_unique(array_map('intval', $merged[$mergeKey]['regions']))); } } return array_values($merged); } function gameplayBuildGameHudFromPost( array $postedComponents, array $sources, array $validRegionIds ): array { $components = []; $validRegionIds = array_values(array_unique(array_map('intval', $validRegionIds))); $validRegionMap = array_fill_keys($validRegionIds, true); foreach (array_values($postedComponents) as $rowIndex => $posted) { if (!is_array($posted)) { continue; } $type = strtolower(trim((string)($posted['type'] ?? 'number'))); if (!in_array($type, ['number', 'progress_bar'], true)) { return [ 'components' => [], 'editor_components' => array_values($postedComponents), 'error' => 'HUD item ' . ($rowIndex + 1) . ' has an unsupported display type.', ]; } $sourceKey = trim((string)($posted['source'] ?? '')); $source = $sources[$sourceKey] ?? null; if (!is_array($source)) { return [ 'components' => [], 'editor_components' => array_values($postedComponents), 'error' => 'HUD item ' . ($rowIndex + 1) . ' must select a numeric source.', ]; } if ($type === 'progress_bar' && empty($source['bounded'])) { return [ 'components' => [], 'editor_components' => array_values($postedComponents), 'error' => 'HUD item ' . ($rowIndex + 1) . ' uses a Progress Bar, but its source does not have both a numeric Minimum and Maximum.', ]; } $regions = []; foreach ((array)($posted['regions'] ?? []) as $regionId) { $regionId = (int)$regionId; if ($regionId > 0 && isset($validRegionMap[$regionId])) { $regions[] = $regionId; } } $regions = array_values(array_unique($regions)); $id = trim((string)($posted['id'] ?? '')); if ($id === '') { $id = 'hud_' . bin2hex(random_bytes(8)); } $components[] = [ 'id' => $id, 'type' => $type, 'source' => $sourceKey, 'label' => trim((string)($posted['label'] ?? '')), 'show_value' => $type === 'number' ? true : !empty($posted['show_value']), /* Empty means every Region. */ 'regions' => $regions, ]; } return [ 'components' => $components, 'editor_components' => $components, 'error' => '', ]; } ================================================================================ FILE: _wayfarer_backups/v95-20260930-180850/play/assets/play.css ================================================================================ /* |-------------------------------------------------------------------------- | Wayfarer Play Shell v20 |-------------------------------------------------------------------------- */ html, body { height: 100%; } body.wayfarer-play-page { margin: 0; overflow: hidden; background: #eef1f5; color: #20242a; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.45; } .wayfarer-play-page * { box-sizing: border-box; } .wayfarer-play-page hr { display: none; } .wayfarer-play-shell { height: 100vh; display: flex; flex-direction: column; overflow: hidden; } .wayfarer-play-shell-header { flex: 0 0 auto; min-height: 68px; padding: 12px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; background: #ffffff; border-bottom: 1px solid #d8dbe2; } .wayfarer-play-title { min-width: 0; } .wayfarer-play-title h1 { margin: 0; font-size: 1.35rem; line-height: 1.2; } .wayfarer-play-title p { margin: 4px 0 0; color: #667085; font-size: 0.9rem; } .wayfarer-shell-actions { display: flex; align-items: center; gap: 8px; } .wayfarer-shell-button { border: 1px solid #b8bdc7; border-radius: 6px; padding: 7px 10px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 600; } .wayfarer-shell-button:hover { background: #f6f7f9; } .wayfarer-play-main { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 7fr) minmax(330px, 3fr); gap: 12px; padding: 12px; overflow: hidden; } .wayfarer-player-pane, .wayfarer-author-pane { min-width: 0; min-height: 0; background: #ffffff; border: 1px solid #d8dbe2; border-radius: 10px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); } .wayfarer-player-pane { overflow: auto; padding: 22px; } .wayfarer-player-pane > h2:first-child { margin-top: 0; } #wayfarer-player-scene { margin-top: 10px; } #wayfarer-player-scene h2 { margin-top: 0; } .wayfarer-author-pane { display: flex; flex-direction: column; overflow: hidden; } .wayfarer-author-header { flex: 0 0 auto; padding: 14px 16px 10px; border-bottom: 1px solid #e3e6eb; } .wayfarer-author-header h2 { margin: 0; font-size: 1.05rem; } .wayfarer-author-header p { margin: 6px 0 0; font-size: 0.85rem; } .wayfarer-author-header a { color: #2f5ea8; } .wayfarer-author-toolbar { flex: 0 0 auto; padding: 10px 14px; background: #f6f7f9; border-bottom: 1px solid #e3e6eb; } .wayfarer-author-toolbar #wayfarer-tick-test { margin: 0; } .wayfarer-author-toolbar #wayfarer-tick-test > h2 { margin: 0 0 6px; font-size: 0.9rem; } .wayfarer-author-toolbar #wayfarer-tick-test > p { margin: 5px 0; font-size: 0.82rem; } .wayfarer-author-toolbar button { margin-right: 4px; margin-bottom: 2px; padding: 5px 8px; } #wayfarer-tick-message { min-height: 1.2em; } .wayfarer-author-tabs { flex: 0 0 auto; display: flex; gap: 2px; padding: 8px 10px 0; background: #ffffff; border-bottom: 1px solid #d8dbe2; } .wayfarer-author-tab { appearance: none; border: 0; border-bottom: 2px solid transparent; padding: 8px 10px 7px; background: transparent; color: #667085; cursor: pointer; font: inherit; font-size: 0.86rem; font-weight: 600; } .wayfarer-author-tab:hover { color: #20242a; } .wayfarer-author-tab.is-active { color: #2f5ea8; border-bottom-color: #2f5ea8; } .wayfarer-author-content { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 14px 16px 18px; } .wayfarer-author-tab-panel { display: none; } .wayfarer-author-tab-panel.is-active { display: block; } .wayfarer-author-tab-panel > :first-child { margin-top: 0; } .wayfarer-author-tab-panel h3 { margin-top: 0; font-size: 1rem; } .wayfarer-author-tab-panel h4 { margin-bottom: 8px; } .wayfarer-author-tab-panel table { width: 100%; border-collapse: collapse; font-size: 0.84rem; } .wayfarer-author-tab-panel th, .wayfarer-author-tab-panel td { padding: 6px 7px; text-align: left; vertical-align: top; } .wayfarer-author-tab-panel pre { max-width: 100%; overflow: auto; font-size: 0.78rem; } #wayfarer-author-debug-panel details { margin-top: 0 !important; } #wayfarer-author-debug-panel summary { display: none; } #wayfarer-author-debug-panel details:not([open]) > *:not(summary) { display: block; } #wayfarer-author-debug-panel details > pre { display: block; max-height: none !important; margin-top: 0 !important; } .wayfarer-player-meta { margin-bottom: 14px; padding: 10px 12px; border-radius: 7px; background: #f6f7f9; color: #667085; font-size: 0.86rem; } .wayfarer-player-meta h2 { margin: 0 0 4px; color: #20242a; font-size: 0.95rem; } .wayfarer-player-meta p { margin: 0; } .wayfarer-play-shell.author-collapsed .wayfarer-play-main { grid-template-columns: minmax(0, 1fr); } .wayfarer-play-shell.author-collapsed .wayfarer-author-pane { display: none; } @media (max-width: 900px) { body.wayfarer-play-page { overflow: auto; } .wayfarer-play-shell { height: auto; min-height: 100vh; overflow: visible; } .wayfarer-play-main { display: block; overflow: visible; padding: 8px; } .wayfarer-player-pane, .wayfarer-author-pane { overflow: visible; margin-bottom: 10px; } .wayfarer-author-pane { min-height: 480px; } .wayfarer-author-content { overflow: visible; } .wayfarer-play-shell.author-collapsed .wayfarer-author-pane { display: none; } } /* ------------------------------------------------------------------ GAME START SCREEN WAYFARER_PLAYER_START_SCREEN_V36 ------------------------------------------------------------------ */ /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * The start screen no longer implies "no Author Mode." Authors may inspect * the Author panel before initialization. The existing author-collapsed class * controls the one-column layout for normal players and collapsed authors. */ .wayfarer-start-screen { width: min(860px, 100%); margin: 0 auto; } .wayfarer-start-screen-heading { margin-bottom: 24px; } .wayfarer-start-screen-heading h2 { margin: 0 0 12px; font-size: 1.65rem; } .wayfarer-start-introduction { max-width: 72ch; color: #4b5563; line-height: 1.65; } .wayfarer-start-error { margin-bottom: 18px; padding: 10px 12px; border: 1px solid #e4a7ad; border-radius: 7px; background: #fff4f5; color: #8b1e2d; } .wayfarer-start-form { display: grid; gap: 22px; } .wayfarer-start-section, .wayfarer-start-choice { margin: 0; padding: 18px; border: 1px solid #d8dbe2; border-radius: 9px; background: #ffffff; } .wayfarer-start-section h3, .wayfarer-start-choice legend { margin: 0 0 14px; color: #20242a; font-size: 1.05rem; font-weight: 700; } .wayfarer-start-choice legend { padding: 0 6px; } .wayfarer-start-names { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } .wayfarer-start-names label { display: grid; gap: 5px; font-weight: 600; } .wayfarer-start-names input { width: 100%; padding: 9px 10px; border: 1px solid #b8bdc7; border-radius: 6px; font: inherit; } .wayfarer-start-choice { display: grid; gap: 10px; } .wayfarer-start-option { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: start; padding: 12px 13px; border: 1px solid #d8dbe2; border-radius: 7px; cursor: pointer; } .wayfarer-start-option:hover { background: #f6f7f9; } .wayfarer-start-option input { margin-top: 4px; } .wayfarer-start-option-copy { display: grid; gap: 4px; } .wayfarer-start-option-copy > span { color: #667085; font-size: 0.92rem; } .wayfarer-start-actions { display: flex; justify-content: flex-end; } .wayfarer-start-button { padding: 10px 18px; border: 1px solid #244b88; border-radius: 7px; background: #2f5ea8; color: #ffffff; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-start-button:hover { background: #244b88; } @media (max-width: 700px) { .wayfarer-start-screen { width: 100%; } .wayfarer-start-section, .wayfarer-start-choice { padding: 14px; } } /* ------------------------------------------------------------------ PLAYER ACTION SCREEN WAYFARER_ACTION_SCREEN_V39 ------------------------------------------------------------------ */ .wayfarer-action-screen { margin-top: 14px; padding: 18px; border: 1px solid #d8dbe2; border-radius: 10px; background: #ffffff; box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); } .wayfarer-action-screen[hidden], .wayfarer-travel-controls[hidden] { display: none !important; } .wayfarer-action-heading { margin-bottom: 14px; } .wayfarer-action-heading h2 { margin: 0 0 4px; font-size: 1.25rem; } .wayfarer-action-heading p { margin: 0; color: #667085; } .wayfarer-action-list { display: grid; gap: 10px; } .wayfarer-action-button { width: 100%; padding: 12px 14px; border: 1px solid #214b86; border-radius: 8px; background: #245a9b; color: #ffffff; cursor: pointer; font: inherit; font-weight: 700; text-align: left; } .wayfarer-action-button:hover { background: #1e4c84; } .wayfarer-action-button:disabled { cursor: default; opacity: 0.55; } /* ------------------------------------------------------------------ IN-CARD ACTION UTILITY VIEWS WAYFARER_ACTION_UTILITY_VIEWS_V48 ------------------------------------------------------------------ */ .wayfarer-action-utility-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 2px; } .wayfarer-action-utility-button, .wayfarer-action-back-button { padding: 10px 12px; border: 1px solid #b8bdc7; border-radius: 7px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 650; } .wayfarer-action-utility-button { width: 100%; text-align: center; } .wayfarer-action-utility-button:hover, .wayfarer-action-back-button:hover { border-color: #98a2b3; background: #f6f7f9; } .wayfarer-action-subview[hidden], .wayfarer-action-heading[hidden], .wayfarer-action-list[hidden] { display: none !important; } .wayfarer-action-subview-heading { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 18px; } .wayfarer-action-subview-heading h2 { margin: 0; font-size: 1.25rem; } .wayfarer-action-back-button { flex: 0 0 auto; } .wayfarer-action-subview-body { min-height: 90px; } /* ------------------------------------------------------------------ ENTITY INVENTORY / STATUS PRESENTATION WAYFARER_ENTITY_PRESENTATION_V49 ------------------------------------------------------------------ */ .wayfarer-inventory-list, .wayfarer-party-list { display: grid; gap: 10px; } .wayfarer-inventory-item, .wayfarer-party-member, .wayfarer-status-section { padding: 13px 14px; border: 1px solid #d8dbe2; border-radius: 8px; background: #f8fafc; } .wayfarer-inventory-item-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; } .wayfarer-inventory-quantity { flex: 0 0 auto; color: #667085; font-size: 0.9rem; font-weight: 650; } .wayfarer-status-section + .wayfarer-status-section { margin-top: 12px; } .wayfarer-status-section > h3 { margin: 0 0 10px; font-size: 1rem; } .wayfarer-status-player-name { margin: 0; font-weight: 700; } .wayfarer-party-member { background: #ffffff; } .wayfarer-party-member h4 { margin: 0; font-size: 0.98rem; } /* WAYFARER_PLAYER_TRAITS_STATUS_V52 */ .wayfarer-player-member h4::after { content: "Player"; margin-left: 8px; color: #667085; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; } .wayfarer-status-values { margin: 10px 0 0; } .wayfarer-status-values > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; padding: 6px 0; border-top: 1px solid #e3e6eb; } .wayfarer-status-values dt { color: #4b5563; } .wayfarer-status-values dd { margin: 0; font-weight: 700; text-align: right; } .wayfarer-status-muted { margin: 8px 0 0; color: #667085; font-size: 0.88rem; } /* ------------------------------------------------------------------ ENTITY DETAIL VIEW WAYFARER_ENTITY_DETAIL_VIEW_V50 ------------------------------------------------------------------ */ .wayfarer-entity-open-button { appearance: none; border: 0; padding: 0; background: transparent; color: #2f5ea8; cursor: pointer; font: inherit; font-weight: 750; text-align: left; } .wayfarer-entity-open-button:hover { text-decoration: underline; } .wayfarer-entity-open-button:focus-visible { outline: 2px solid #2f5ea8; outline-offset: 3px; border-radius: 2px; } .wayfarer-status-player-names { display: grid; gap: 4px; } .wayfarer-status-player-names .wayfarer-status-player-name { margin: 0; } .wayfarer-entity-detail-eyebrow { margin: 0 0 3px; color: #667085; font-size: 0.78rem; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; } .wayfarer-entity-detail-layout { display: grid; grid-template-columns: minmax(180px, 280px) minmax(0, 1fr); gap: 22px; align-items: start; } .wayfarer-entity-detail-layout:not(:has(.wayfarer-entity-detail-media)) { grid-template-columns: minmax(0, 1fr); } .wayfarer-entity-detail-media { margin: 0; } .wayfarer-entity-detail-media img { display: block; width: 100%; max-height: 340px; object-fit: contain; border-radius: 8px; background: #f6f7f9; } .wayfarer-entity-detail-description { margin: 0 0 16px; line-height: 1.6; } .wayfarer-entity-detail-copy > :first-child { margin-top: 0; } @media (max-width: 700px) { .wayfarer-entity-detail-layout { grid-template-columns: 1fr; } .wayfarer-entity-detail-media img { max-height: 280px; } } .wayfarer-action-empty { margin: 0; color: #667085; font-size: 0.92rem; } .wayfarer-travel-controls { margin-top: 14px; padding: 12px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid #d8dbe2; border-radius: 10px; background: #ffffff; } .wayfarer-travel-status { display: flex; align-items: center; gap: 9px; font-weight: 700; } /* WAYFARER_TRAVEL_TIME_HUD_V61 */ .wayfarer-travel-copy { min-width: 0; } .wayfarer-travel-primary { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px; } .wayfarer-travel-time { font-weight: 800; } .wayfarer-travel-time-separator { color: #98a2b3; } .wayfarer-travel-next { margin-top: 3px; color: #667085; font-size: 0.88rem; font-weight: 600; } .wayfarer-travel-indicator { width: 9px; height: 9px; border-radius: 999px; background: currentColor; } .wayfarer-travel-pause { padding: 8px 12px; border: 1px solid #aab4c3; border-radius: 7px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 600; } .wayfarer-travel-pause:hover { background: #f6f7f9; } @media (max-width: 620px) { .wayfarer-action-screen { padding: 14px; } .wayfarer-travel-controls { align-items: stretch; flex-direction: column; } .wayfarer-travel-pause { width: 100%; } .wayfarer-action-utility-row { grid-template-columns: 1fr; } } /* ------------------------------------------------------------------ LOCATION / STORY PRESENTATION WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 ------------------------------------------------------------------ */ /* * Scene prose now belongs to Presentation Mode rather than the normal Action * page. The server-rendered scene remains in the DOM temporarily for runtime * compatibility but is not player-facing. */ #wayfarer-player-scene { display: none; } .wayfarer-action-parent-context { margin-bottom: 4px !important; font-size: 0.85rem; font-weight: 600; } /* ------------------------------------------------------------------ STORYLET ACTIONS / BRANCHES WAYFARER_STORYLET_ACTION_RUNTIME_V59 ------------------------------------------------------------------ */ .wayfarer-storylet-action-list { display: grid; gap: 10px; } .wayfarer-storylet-action-list:empty { display: none; } .wayfarer-storylet-branch-list { width: 100%; display: grid; gap: 10px; } .wayfarer-presentation-actions.wayfarer-has-storylet-branches > div:not(.wayfarer-storylet-branch-list) { display: none; } .wayfarer-storylet-branch-prompt { margin: 0 0 4px; color: #b8c0cc; font-weight: 700; } .wayfarer-storylet-branch-button { width: 100%; padding: 12px 14px; border: 1px solid #f8fafc; border-radius: 8px; background: transparent; color: #f8fafc; cursor: pointer; font: inherit; font-weight: 700; text-align: left; } .wayfarer-storylet-branch-button:hover, .wayfarer-storylet-branch-button:focus-visible { background: #f8fafc; color: #15181d; } .wayfarer-storylet-branch-button:disabled { cursor: default; opacity: 0.55; } .wayfarer-presentation-screen { position: fixed; z-index: 10000; inset: 0; overflow-y: auto; padding: clamp(24px, 6vh, 72px) 24px; background: #15181d; color: #f8fafc; } .wayfarer-presentation-screen[hidden] { display: none !important; } .wayfarer-presentation-inner { width: min(1040px, 100%); min-height: calc(100vh - clamp(48px, 12vh, 144px)); margin: 0 auto; display: flex; flex-direction: column; justify-content: center; } .wayfarer-presentation-media { width: 100%; margin: 0 0 26px; overflow: hidden; border-radius: 12px; background: #0d0f12; } .wayfarer-presentation-media img { display: block; width: 100%; max-height: 52vh; object-fit: cover; } .wayfarer-presentation-copy { width: min(760px, 100%); margin: 0 auto; } .wayfarer-presentation-eyebrow { margin: 0 0 8px; color: #b8c0cc; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .wayfarer-presentation-copy h1 { margin: 0 0 18px; color: inherit; font-size: clamp(2rem, 5vw, 3.8rem); line-height: 1.05; } .wayfarer-presentation-description { font-size: clamp(1rem, 2vw, 1.22rem); line-height: 1.7; } .wayfarer-presentation-actions { width: min(760px, 100%); margin: 32px auto 0; display: flex; justify-content: flex-end; } .wayfarer-presentation-continue { min-width: 150px; padding: 11px 18px; border: 1px solid #f8fafc; border-radius: 8px; background: #f8fafc; color: #15181d; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-presentation-continue:hover { background: #e8edf3; } .wayfarer-presentation-continue:focus-visible { outline: 3px solid #ffffff; outline-offset: 4px; } .wayfarer-presentation-key-hint { margin: 10px 0 0; color: #9ea8b6; font-size: 0.78rem; text-align: right; } body.wayfarer-runtime-presentation { overflow: hidden; } @media (max-width: 620px) { .wayfarer-presentation-screen { padding: 20px 14px; } .wayfarer-presentation-inner { min-height: calc(100vh - 40px); } .wayfarer-presentation-actions { justify-content: stretch; } .wayfarer-presentation-continue { width: 100%; } } /* ------------------------------------------------------------------ COMBINED AUTHOR STATE + REGION WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 ------------------------------------------------------------------ */ .wayfarer-author-state-region { margin-top: 20px; padding-top: 14px; border-top: 1px solid #e4e7ec; } .wayfarer-author-state-region > :first-child { margin-top: 0; } /* ------------------------------------------------------------------ TERMINAL GAME ENDING WAYFARER_GAME_ENDING_SCREEN_V45 ------------------------------------------------------------------ */ .wayfarer-ending-screen { position: fixed; z-index: 11000; inset: 0; overflow-y: auto; padding: clamp(24px, 6vh, 72px) 24px; background: #11151a; color: #f8fafc; } .wayfarer-ending-inner { width: min(1040px, 100%); min-height: calc(100vh - clamp(48px, 12vh, 144px)); margin: 0 auto; display: flex; flex-direction: column; justify-content: center; } .wayfarer-ending-media { width: 100%; margin: 0 0 28px; overflow: hidden; border-radius: 12px; background: #080a0d; } .wayfarer-ending-media img { display: block; width: 100%; max-height: 52vh; object-fit: cover; } .wayfarer-ending-copy { width: min(760px, 100%); margin: 0 auto; } .wayfarer-ending-eyebrow { margin: 0 0 8px; color: #aeb8c5; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .wayfarer-ending-copy h1 { margin: 0 0 18px; color: inherit; font-size: clamp(2rem, 5vw, 3.8rem); line-height: 1.05; } .wayfarer-ending-text { font-size: clamp(1rem, 2vw, 1.22rem); line-height: 1.7; } body.wayfarer-game-ended { overflow: hidden; } /* ------------------------------------------------------------------ PRESENTATION TRANSITION + ENDING RESTART WAYFARER_PRESENTATION_TRANSITION_V63 WAYFARER_ENDING_RESTART_V63 ------------------------------------------------------------------ */ body.wayfarer-presentation-transitioning::after { content: ""; position: fixed; z-index: 10500; inset: 0; background: #15181d; pointer-events: all; } .wayfarer-ending-actions { margin-top: 30px; display: flex; justify-content: flex-end; } .wayfarer-ending-restart { min-width: 150px; padding: 11px 18px; border: 1px solid #f8fafc; border-radius: 8px; background: #f8fafc; color: #15181d; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-ending-restart:hover { background: #e8edf3; } .wayfarer-ending-restart:focus-visible { outline: 3px solid #ffffff; outline-offset: 4px; } .wayfarer-ending-restart:disabled { cursor: default; opacity: 0.6; } @media (max-width: 620px) { .wayfarer-ending-actions { justify-content: stretch; } .wayfarer-ending-restart { width: 100%; } } /* ------------------------------------------------------------------ GAME TRAVEL HUD WAYFARER_GAME_TRAVEL_HUD_STYLE_V94 ------------------------------------------------------------------ */ .wayfarer-player-pane { display: flex; flex-direction: column; } .wayfarer-player-hud { flex: 0 0 auto; margin-top: 12px; } .wayfarer-player-hud[hidden] { display: none !important; } .wayfarer-hud-components { display: grid; grid-template-columns: repeat( auto-fit, minmax( min(180px, 100%), 1fr ) ); gap: 10px; } .wayfarer-hud-component { min-width: 0; padding: 10px 12px; border: 1px solid #d8dbe2; border-radius: 9px; background: #f8fafc; } .wayfarer-hud-number-component { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } .wayfarer-hud-number { flex: 0 0 auto; font-size: 1rem; font-variant-numeric: tabular-nums; } .wayfarer-hud-progress-component { min-width: 0; } .wayfarer-hud-progress-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 7px; } .wayfarer-hud-label { min-width: 0; overflow: hidden; font-size: 0.84rem; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } .wayfarer-hud-value { flex: 0 0 auto; color: #667085; font-size: 0.82rem; font-variant-numeric: tabular-nums; } .wayfarer-hud-progress-track { height: 8px; overflow: hidden; border-radius: 999px; background: #dfe4ea; } .wayfarer-hud-progress-fill { height: 100%; min-width: 0; border-radius: inherit; background: #2f5ea8; transition: width 180ms ease; } @media (max-width: 620px) { .wayfarer-player-hud { margin-top: 10px; } .wayfarer-hud-components { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } } @media (max-width: 420px) { .wayfarer-hud-components { grid-template-columns: 1fr; } } /* ------------------------------------------------------------------ REGION HUD AUTHORING WAYFARER_REGION_HUD_EDITOR_V64 ------------------------------------------------------------------ */ .builder-region-hud { margin: 0; } .builder-region-hud > h3 { margin-top: 0; } .builder-region-hud-component { margin: 14px 0; padding: 14px 16px; border: 1px solid #d8dbe2; border-radius: 8px; } .builder-region-hud-component legend { padding: 0 6px; font-weight: 700; } .builder-region-hud-component select, .builder-region-hud-component input[type="text"] { width: min(560px, 100%); max-width: 100%; } /* ------------------------------------------------------------------ ONE-TIME EFFECT PLAYER FEEDBACK WAYFARER_EFFECT_FEEDBACK_V70 ------------------------------------------------------------------ */ .wayfarer-presentation-changes { margin-top: 18px; padding: 12px 14px; border: 1px solid rgba(31, 41, 55, 0.16); border-radius: 8px; background: rgba(255, 255, 255, 0.68); } .wayfarer-presentation-changes-heading { margin: 0 0 7px; font-size: 0.78rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.68; } .wayfarer-presentation-changes-list { display: grid; gap: 5px; margin: 0; padding: 0; list-style: none; } .wayfarer-presentation-change { margin: 0; font-weight: 700; line-height: 1.35; } .wayfarer-presentation-change.is-gain::before, .wayfarer-presentation-change.is-loss::before { display: inline-block; width: 0; overflow: hidden; content: ''; } ================================================================================ FILE: _wayfarer_backups/v95-20260930-180850/play/assets/play.js ================================================================================ /* |-------------------------------------------------------------------------- | PLAYER RUNTIME CONTROLLER | WAYFARER_ACTION_SCREEN_V39 | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | Runtime modes currently exposed to the player: | | presentation -> Action -> running navigation -> presentation/Action | | Presentation is persisted by PHP. Automatic tick advancement cannot continue | while a presentation is active. | */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; if (!config) { return; } const body = document.body; const tickValue = document.getElementById( 'wayfarer-tick-value' ); const statusValue = document.getElementById( 'wayfarer-tick-status' ); const message = document.getElementById( 'wayfarer-tick-message' ); const onceButton = document.getElementById( 'wayfarer-tick-once' ); const autoButton = document.getElementById( 'wayfarer-tick-auto' ); const authorPauseButton = document.getElementById( 'wayfarer-tick-pause' ); const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); /* WAYFARER_PLAYER_HUD_V64 */ const playerHud = document.querySelector( '[data-wayfarer-hud]' ); /* WAYFARER_TRAVEL_TIME_HUD_V61 */ const travelTime = document.querySelector( '[data-wayfarer-travel-time]' ); const travelTimeSeparator = document.querySelector( '[data-wayfarer-travel-time-separator]' ); const travelNext = document.querySelector( '[data-wayfarer-travel-next]' ); const continueTravelButton = document.querySelector( '[data-wayfarer-continue-travel]' ); const playerPauseButton = document.querySelector( '[data-wayfarer-pause-travel]' ); /* WAYFARER_PLAYER_TURN_AROUND_UI_V72 */ const turnAroundButton = document.querySelector( '[data-wayfarer-turn-around]' ); /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Action Mode owns one physical card. Inventory, Status, and future * utility/context views temporarily replace the normal action heading/list * inside that card without becoming persisted runtime modes. */ const actionHeading = actionScreen ? actionScreen.querySelector( '.wayfarer-action-heading' ) : null; const actionList = actionScreen ? actionScreen.querySelector( '[data-wayfarer-action-list]' ) : null; const actionSubviewPanels = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ) : []; const actionViewOpeners = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-open-action-view]' ) ) : []; const actionViewClosers = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-close-action-view]' ) ) : []; /* * Persisted narrative presentation. */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); const presentationContinueButton = presentationScreen ? presentationScreen.querySelector( '[data-wayfarer-presentation-continue]' ) : null; let running = false; let requestInFlight = false; let presentationRequestInFlight = false; let storyletRequestInFlight = false; let timeoutId = null; let reloadWhenIdle = false; /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ let requestedTravelDirection = Number( config.initialDirection ) < 0 ? -1 : 1; let presentationActive = Boolean( presentationScreen && !presentationScreen.hidden ); function setMessage( text, isError = false ) { if (!message) { return; } message.textContent = text; message.style.color = isError ? 'red' : ''; } function emitModeEvent() { let mode = 'paused'; if (presentationActive) { mode = 'presentation'; } else if (running) { mode = 'running'; } window.dispatchEvent( new CustomEvent( 'wayfarer:runtime-mode', { detail: { mode: mode } } ) ); /* * Keep the v39 event names available for existing development hooks. */ window.dispatchEvent( new CustomEvent( running ? 'wayfarer:runtime-running' : 'wayfarer:runtime-paused', { detail: { mode: mode } } ) ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Generic in-card view switcher. Future Map / Shop / other views can use * the same data-wayfarer-open-action-view + data-wayfarer-action-view * contract without adding another page-level layout system. */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Resolve available views at the moment navigation occurs instead of * relying on the V48 page-load snapshot. This supports nested Entity * detail screens and future context views added to the same Action card. */ function setActionView(viewName = 'actions') { if (!actionScreen) { return; } const requested = String( viewName || 'actions' ); const showActions = requested === 'actions'; const panels = Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ); let matched = showActions; panels.forEach( function (panel) { const active = panel.dataset .wayfarerActionView === requested; panel.hidden = !active; if (active) { matched = true; } } ); /* * Fail safely if a malformed/removed view name is requested. */ if (!matched) { panels.forEach( function (panel) { panel.hidden = true; } ); if (actionHeading) { actionHeading.hidden = false; } if (actionList) { actionList.hidden = false; } return; } if (actionHeading) { actionHeading.hidden = !showActions; } if (actionList) { actionList.hidden = !showActions; } } function renderControls() { if (statusValue) { statusValue.textContent = presentationActive ? 'Presentation' : ( running ? 'Running' : 'Paused' ); } if (onceButton) { onceButton.disabled = running || requestInFlight || presentationActive; } if (autoButton) { autoButton.disabled = running || requestInFlight || presentationActive; } if (authorPauseButton) { authorPauseButton.disabled = !running; } if (continueTravelButton) { continueTravelButton.disabled = running || requestInFlight || presentationActive; } document .querySelectorAll( '[data-wayfarer-storylet-action-id], ' + '[data-wayfarer-storylet-branch-id]' ) .forEach( function (button) { button.disabled = requestInFlight || presentationRequestInFlight || storyletRequestInFlight || ( button.hasAttribute( 'data-wayfarer-storylet-action-id' ) && (running || presentationActive) ); } ); if (playerPauseButton) { playerPauseButton.disabled = !running; } /* WAYFARER_PLAYER_TURN_AROUND_UI_V72 */ if (turnAroundButton) { turnAroundButton.disabled = running || requestInFlight || presentationActive; } if (actionScreen) { actionScreen.hidden = running || presentationActive; } if (travelControls) { travelControls.hidden = !running || presentationActive; } /* WAYFARER_GAME_TRAVEL_HUD_VISIBILITY_V94 */ if (playerHud) { playerHud.hidden = presentationActive || !running; } if (presentationScreen) { presentationScreen.hidden = !presentationActive; presentationScreen.setAttribute( 'aria-hidden', presentationActive ? 'false' : 'true' ); } body.classList.toggle( 'wayfarer-runtime-running', running ); body.classList.toggle( 'wayfarer-runtime-paused', !running && !presentationActive ); body.classList.toggle( 'wayfarer-runtime-presentation', presentationActive ); } /* * WAYFARER_LIVE_ACTION_CONTEXT_V46_1 * * Action Mode must reflect the current runtime position, not the page's * original server-rendered context. */ function renderPlayerContext(context) { if ( !context || typeof context !== 'object' ) { return; } const heading = document.querySelector( '[data-wayfarer-action-context-name]' ); const parent = document.querySelector( '[data-wayfarer-action-parent-context]' ); if (heading) { const name = String( context.context_name || 'Actions' ).trim(); heading.textContent = name || 'Actions'; } if (parent) { const parentText = String( context.parent_context || '' ).trim(); parent.textContent = parentText; parent.hidden = parentText === ''; } } /* WAYFARER_TRAVEL_TIME_HUD_RENDER_V61 */ function renderTravelStatus(travel) { if (!travel || typeof travel !== 'object') { return; } const timeLabel = String(travel.time_label || '').trim(); if (travelTime) { travelTime.textContent = timeLabel; travelTime.hidden = timeLabel === ''; } if (travelTimeSeparator) { travelTimeSeparator.hidden = timeLabel === ''; } const nextLabel = String(travel.next_label || '').trim(); if (travelNext) { travelNext.textContent = nextLabel === '' ? '' : 'Next: ' + nextLabel; travelNext.hidden = nextLabel === ''; } } function displayRuntimeValue(value) { if (typeof value === 'boolean') { return value ? 'True' : 'False'; } if ( value === null || value === undefined ) { return 'null'; } if (typeof value === 'object') { return JSON.stringify( value ); } return String( value ); } /* * WAYFARER_STORYLET_ACTIONS_LIVE_V59 * * Tick/presentation APIs return the currently eligible root Manual * Storylets so Action Mode does not depend on the page-load snapshot. */ function renderStoryletActions(actions) { if (!actionScreen) { return; } const host = actionScreen.querySelector( '[data-wayfarer-storylet-actions]' ); if (!host) { return; } const normalized = Array.isArray(actions) ? actions : []; host.innerHTML = ''; normalized.forEach( function (action) { const id = Number( action && action.id ? action.id : 0 ); if (id <= 0) { return; } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-action-button'; button.dataset.wayfarerStoryletActionId = String(id); button.textContent = String( action.label || action.name || 'Storylet' ); host.appendChild( button ); } ); const empty = actionScreen.querySelector( '[data-wayfarer-action-empty]' ); if (empty) { const canTravel = actionScreen.dataset.wayfarerCanTravel === '1'; empty.hidden = canTravel || host.children.length > 0; } renderControls(); } /* * WAYFARER_GAME_TRAVEL_HUD_LIVE_V94 * * HUD components carry a direct runtime Trait address. Tick responses * already include canonical state, so Number and Progress Bar values can * update in place without a separate HUD endpoint. */ function resolveHudTraits(state, scope) { if (!state || typeof state !== 'object') { return null; } if (scope === 'game') { return state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : null; } const match = String(scope || '').match(/^(region|location|entity):([1-9][0-9]*)$/); if (!match) { return null; } const bucket = match[1] === 'region' ? 'regions' : (match[1] === 'location' ? 'locations' : 'entities'); const key = match[2]; return state[bucket] && state[bucket][key] && state[bucket][key].traits && typeof state[bucket][key].traits === 'object' ? state[bucket][key].traits : null; } function formatHudNumber(value) { const number = Number(value); if (!Number.isFinite(number)) { return String(value ?? ''); } return Number.isInteger(number) ? String(number) : String(Math.round(number * 100) / 100); } function renderHudState(state) { if (!playerHud || !state || typeof state !== 'object') { return; } playerHud .querySelectorAll('[data-wayfarer-hud-component]') .forEach(function (component) { const type = String(component.dataset.wayfarerHudType || 'number'); const scope = String(component.dataset.wayfarerHudScope || ''); const sourceId = String(component.dataset.wayfarerHudSourceId || ''); const traits = resolveHudTraits(state, scope); if ( !traits || !Object.prototype.hasOwnProperty.call(traits, sourceId) ) { return; } const value = Number(traits[sourceId]); if (!Number.isFinite(value)) { return; } const valueNode = component.querySelector('[data-wayfarer-hud-value]'); if (valueNode) { valueNode.textContent = formatHudNumber(value); } if (type !== 'progress_bar') { return; } const min = Number(component.dataset.wayfarerHudMin); const max = Number(component.dataset.wayfarerHudMax); if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) { return; } const percent = Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)); const fill = component.querySelector('[data-wayfarer-hud-fill]'); if (fill) { fill.style.width = percent + '%'; } const meter = component.querySelector('[data-wayfarer-hud-meter]'); if (meter) { meter.setAttribute('aria-valuenow', String(value)); } }); } function renderRuntimeState(state) { if ( !state || typeof state !== 'object' ) { return; } renderHudState( state ); if ( state.navigation && state.navigation.position !== undefined ) { const position = String( state.navigation.position ); const authorPosition = document.getElementById( 'wayfarer-author-position' ); if (authorPosition) { authorPosition.textContent = position; } } const gameTraits = state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : {}; document .querySelectorAll( '[data-wayfarer-game-trait-value]' ) .forEach( function (cell) { const traitId = cell.dataset .wayfarerGameTraitValue; if ( Object.prototype .hasOwnProperty .call( gameTraits, traitId ) ) { cell.textContent = displayRuntimeValue( gameTraits[traitId] ); } } ); document .querySelectorAll( '[data-wayfarer-region-trait-value]' ) .forEach( function (cell) { const regionId = cell.dataset .wayfarerRegionId; const traitId = cell.dataset .wayfarerRegionTraitValue; const traits = state.regions && state.regions[regionId] && state.regions[regionId].traits && typeof state.regions[regionId].traits === 'object' ? state.regions[regionId].traits : null; if ( traits && Object.prototype .hasOwnProperty .call( traits, traitId ) ) { cell.textContent = displayRuntimeValue( traits[traitId] ); } } ); const debug = document.getElementById( 'wayfarer-runtime-debug' ); if (debug) { debug.textContent = JSON.stringify( state, null, 2 ); } } async function requestTick() { if ( requestInFlight || presentationActive ) { return null; } requestInFlight = true; renderControls(); try { const response = await fetch( config.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ travel_direction: requestedTravelDirection }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Tick endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Tick request failed.' ); } if (tickValue) { tickValue.textContent = String( data.tick ); } renderRuntimeState( data.state ); /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ if ( data.state && data.state.navigation && Object.prototype .hasOwnProperty .call( data.state.navigation, 'direction' ) ) { requestedTravelDirection = Number( data.state.navigation.direction ) < 0 ? -1 : 1; } renderPlayerContext( data.context ); renderTravelStatus( data.travel ); renderStoryletActions( data.actions ); setMessage( 'Tick ' + data.tick + ' saved.' ); if (data.pause === true) { /* * Suppress Action Mode during the brief interval before reload. * The server-rendered presentation will exist after reload. */ presentationActive = true; pauseRuntime( data.pause_reason || 'Travel interrupted.', true ); } return data; } catch (error) { pauseRuntime( error instanceof Error ? error.message : 'Tick request failed.' ); setMessage( error instanceof Error ? error.message : 'Tick request failed.', true ); return null; } finally { requestInFlight = false; renderControls(); if (reloadWhenIdle) { reloadWhenIdle = false; window.location.reload(); } } } async function runAutomaticTick() { if ( !running || presentationActive ) { return; } const result = await requestTick(); if ( !running || presentationActive || result === null ) { return; } timeoutId = window.setTimeout( runAutomaticTick, Number( config.intervalMs ) || 1500 ); } function startRuntime() { if ( running || requestInFlight || presentationActive ) { return; } /* * Travel always leaves inspection/context views and returns the card to * its normal Action view for the next pause. */ setActionView( 'actions' ); running = true; setMessage( 'Automatic advancement started.' ); renderControls(); emitModeEvent(); runAutomaticTick(); } /* * WAYFARER_PRESENTATION_TRANSITION_V63 * * Cover the current DOM before a server-rendered Presentation replaces it. * This prevents the previous Location/Storylet copy from being painted for * a frame during the navigation boundary. */ function beginPresentationTransition() { body.classList.add( 'wayfarer-presentation-transitioning' ); } function pauseRuntime( reason = 'Automatic advancement paused.', reloadForPresentation = false ) { const wasRunning = running; running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } setMessage( reason ); if (wasRunning) { setActionView( 'actions' ); } renderControls(); if (wasRunning) { emitModeEvent(); } if (reloadForPresentation) { /* WAYFARER_PRESENTATION_TRANSITION_V63 */ beginPresentationTransition(); if (requestInFlight) { reloadWhenIdle = true; } else { window.location.reload(); } } } /* * WAYFARER_STORYLET_ACTION_RUNTIME_V59 */ async function requestStorylet( storyletId, entryMode = 'action', parentStoryletId = 0 ) { if ( storyletRequestInFlight || requestInFlight || presentationRequestInFlight ) { return; } storyletId = Number(storyletId || 0); parentStoryletId = Number(parentStoryletId || 0); if (storyletId <= 0) { return; } storyletRequestInFlight = true; renderControls(); try { const response = await fetch( config.storyletEndpoint || 'api/storylet.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, storylet_id: storyletId, entry_mode: entryMode, parent_storylet_id: parentStoryletId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Storylet endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not enter Storylet.' ); } /* * Storylet presentation/Branch markup is server-rendered. Reload * only after the runtime transaction succeeds, so Effects cannot be * repeated by a failed or duplicate browser transition. */ /* WAYFARER_PRESENTATION_TRANSITION_V63 */ beginPresentationTransition(); window.location.reload(); } catch (error) { window.alert( error instanceof Error ? error.message : 'Could not enter Storylet.' ); } finally { storyletRequestInFlight = false; renderControls(); } } async function dismissPresentation() { if ( !presentationActive || !presentationScreen || presentationRequestInFlight ) { return; } presentationRequestInFlight = true; if (presentationContinueButton) { presentationContinueButton.disabled = true; } const presentationType = presentationScreen.dataset .wayfarerPresentationType || ''; const presentationId = Number( presentationScreen.dataset .wayfarerPresentationId || 0 ); try { const response = await fetch( config.presentationEndpoint || 'api/presentation.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, type: presentationType, id: presentationId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Presentation endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not continue.' ); } presentationActive = false; setActionView( 'actions' ); renderRuntimeState( data.state ); renderPlayerContext( data.context ); renderStoryletActions( data.actions ); renderControls(); emitModeEvent(); /* * Place keyboard focus on the first available Action so the * transition works cleanly for keyboard players too. */ const firstAction = actionScreen ? actionScreen.querySelector( 'button:not([disabled]), a[href]' ) : null; if ( firstAction && typeof firstAction.focus === 'function' ) { firstAction.focus(); } } catch (error) { if (presentationContinueButton) { presentationContinueButton.disabled = false; } window.alert( error instanceof Error ? error.message : 'Could not continue.' ); } finally { presentationRequestInFlight = false; /* WAYFARER_PRESENTATION_CONTROL_RESET_V62 * * dismissPresentation() previously refreshed Action controls while * presentationRequestInFlight was still true. Newly-rendered Manual * Storylet buttons were therefore left disabled after a Location or * Storylet presentation was dismissed. Re-render once the request * flag has actually been cleared. */ renderControls(); if ( presentationContinueButton && presentationActive ) { presentationContinueButton.disabled = false; } } } if (onceButton) { onceButton.addEventListener( 'click', async function () { if ( running || presentationActive ) { return; } setMessage(''); await requestTick(); } ); } if (autoButton) { autoButton.addEventListener( 'click', startRuntime ); } if (authorPauseButton) { authorPauseButton.addEventListener( 'click', function () { pauseRuntime( 'Automatic advancement paused.' ); } ); } /* WAYFARER_PLAYER_TURN_AROUND_UI_V72 */ if (turnAroundButton) { turnAroundButton.addEventListener( 'click', function () { requestedTravelDirection = requestedTravelDirection < 0 ? 1 : -1; startRuntime(); } ); } if (continueTravelButton) { continueTravelButton.addEventListener( 'click', startRuntime ); } if (playerPauseButton) { playerPauseButton.addEventListener( 'click', function () { /* * WAYFARER_SMOOTH_MANUAL_PAUSE_V46_2 * * v46.1 keeps Action context current in-place, so manual Pause * no longer needs a full page reload. */ pauseRuntime( 'Travel paused.' ); } ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Ensure V50 detail panels physically belong to the Action card. This is * harmless when the PHP view already placed them correctly, and repairs * any earlier V50 markup that landed just outside the host. */ if (actionScreen) { document .querySelectorAll( '.wayfarer-entity-detail-view' ) .forEach( function (panel) { if ( !actionScreen.contains( panel ) ) { actionScreen.appendChild( panel ); } } ); /* * Delegate navigation from the Action card itself. Any current or * future control using the generic data attributes automatically * participates in the same router. */ actionScreen.addEventListener( 'click', function (event) { const storyletAction = event.target.closest( '[data-wayfarer-storylet-action-id]' ); if ( storyletAction && actionScreen.contains( storyletAction ) ) { if ( running || presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( storyletAction.dataset .wayfarerStoryletActionId, 'action', 0 ); return; } const opener = event.target.closest( '[data-wayfarer-open-action-view]' ); if ( opener && actionScreen.contains( opener ) ) { if ( running || presentationActive ) { return; } event.preventDefault(); setActionView( opener.dataset .wayfarerOpenActionView || 'actions' ); return; } const closer = event.target.closest( '[data-wayfarer-close-action-view]' ); if ( !closer || !actionScreen.contains( closer ) ) { return; } event.preventDefault(); const returnView = closer.dataset .wayfarerReturnActionView || 'actions'; setActionView( returnView ); let focusTarget = null; if (returnView === 'actions') { focusTarget = actionList ? actionList.querySelector( 'button:not([disabled]), a[href]' ) : null; } else { const parentPanel = actionScreen.querySelector( '[data-wayfarer-action-view="' + returnView + '"]' ); focusTarget = parentPanel ? parentPanel.querySelector( '[data-wayfarer-open-action-view], button:not([disabled]), a[href]' ) : null; } if ( focusTarget && typeof focusTarget.focus === 'function' ) { focusTarget.focus(); } } ); } /* WAYFARER_STORYLET_BRANCH_SELECTION_V59 */ document.addEventListener( 'click', function (event) { const branch = event.target.closest( '[data-wayfarer-storylet-branch-id]' ); if (!branch) { return; } if ( !presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( branch.dataset .wayfarerStoryletBranchId, 'branch', branch.dataset .wayfarerStoryletParentId ); } ); if (presentationContinueButton) { presentationContinueButton.addEventListener( 'click', dismissPresentation ); } document.addEventListener( 'keydown', function (event) { if ( !presentationActive || presentationRequestInFlight || storyletRequestInFlight || !presentationContinueButton ) { return; } const target = event.target; if ( target && target.matches && target.matches( 'input, textarea, select, button, a[href], [contenteditable="true"]' ) ) { return; } if ( event.key !== 'Enter' && event.key !== ' ' ) { return; } event.preventDefault(); dismissPresentation(); } ); window.addEventListener( 'pagehide', function () { running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } } ); window.wayfarerRuntimeController = { start: startRuntime, pause: pauseRuntime, dismissPresentation: dismissPresentation, enterStorylet: requestStorylet, isRunning: function () { return running; }, mode: function () { if (presentationActive) { return 'presentation'; } return running ? 'running' : 'paused'; } }; setActionView( 'actions' ); renderControls(); emitModeEvent(); })(); /* |-------------------------------------------------------------------------- | WAYFARER PLAY SHELL v20 |-------------------------------------------------------------------------- | | Reorganizes existing server-rendered nodes into the viewport shell. | Existing nodes are MOVED, not cloned/replaced, so the tick-control | event listeners installed above remain intact. | */ (() => { 'use strict'; if ( document.getElementById( 'wayfarer-play-shell' ) ) { return; } function exactHeading( level, text ) { const headings = document.querySelectorAll( level ); for (const heading of headings) { if ( heading.textContent .replace(/\s+/g, ' ') .trim() === text ) { return heading; } } return null; } function moveRange( start, stopExclusive, destination ) { if ( !start || !destination ) { return; } let node = start; while ( node && node !== stopExclusive ) { const next = node.nextSibling; destination.appendChild( node ); node = next; } } const body = document.body; /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * PHP decides whether Author Mode is permitted. JavaScript only shapes the * interface around that server-side decision. */ const canUseAuthorMode = body.dataset.wayfarerAuthorMode === '1'; body.classList.add( 'wayfarer-play-page' ); const shell = document.createElement( 'div' ); shell.id = 'wayfarer-play-shell'; shell.className = 'wayfarer-play-shell'; const header = document.createElement( 'header' ); header.className = 'wayfarer-play-shell-header'; const title = document.createElement( 'div' ); title.className = 'wayfarer-play-title'; const actions = document.createElement( 'div' ); actions.className = 'wayfarer-shell-actions'; const toggleAuthor = document.createElement( 'button' ); toggleAuthor.type = 'button'; toggleAuthor.className = 'wayfarer-shell-button'; if (canUseAuthorMode) { actions.appendChild( toggleAuthor ); } header.appendChild( title ); if (canUseAuthorMode) { header.appendChild( actions ); } const main = document.createElement( 'main' ); main.className = 'wayfarer-play-main'; const playerPane = document.createElement( 'section' ); playerPane.className = 'wayfarer-player-pane'; const authorPane = document.createElement( 'aside' ); authorPane.className = 'wayfarer-author-pane'; const authorHeader = document.createElement( 'div' ); authorHeader.className = 'wayfarer-author-header'; const authorToolbar = document.createElement( 'div' ); authorToolbar.className = 'wayfarer-author-toolbar'; const tabBar = document.createElement( 'div' ); tabBar.className = 'wayfarer-author-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const authorContent = document.createElement( 'div' ); authorContent.className = 'wayfarer-author-content'; authorPane.appendChild( authorHeader ); authorPane.appendChild( authorToolbar ); authorPane.appendChild( tabBar ); authorPane.appendChild( authorContent ); main.appendChild( playerPane ); if (canUseAuthorMode) { main.appendChild( authorPane ); } else { /* * Keep the player workspace full-width when Author Mode is unavailable. * No Author pane was rendered by PHP and none is attached here. */ shell.classList.add( 'author-collapsed' ); } shell.appendChild( header ); shell.appendChild( main ); body.insertBefore( shell, body.firstChild ); // ------------------------------------------------------------ // Top title/player identity // ------------------------------------------------------------ const pageTitle = Array.from( body.querySelectorAll( ':scope > h1' ) )[0]; if (pageTitle) { const playerIdentity = pageTitle.nextElementSibling; title.appendChild( pageTitle ); if ( playerIdentity && playerIdentity.tagName === 'P' ) { title.appendChild( playerIdentity ); } } // ------------------------------------------------------------ // Player content // ------------------------------------------------------------ /* * WAYFARER_PLAYER_START_SCREEN_V36 * * Before runtime state exists, the start screen owns the full player * workspace and Author Mode is intentionally hidden. */ const startScreen = document.querySelector( '[data-wayfarer-start-screen]' ); if (startScreen) { shell.classList.add( 'is-starting' ); playerPane.appendChild( startScreen ); } const scene = document.getElementById( 'wayfarer-player-scene' ); const gameStateHeading = exactHeading( 'h2', 'Game State' ) || exactHeading( 'h2', 'Game Not Initialized' ); if (gameStateHeading) { const meta = document.createElement( 'div' ); meta.className = 'wayfarer-player-meta'; /* * Move from Game State through the node immediately before the * player scene. This includes initialization messaging/forms but * stops before the actual scene. */ moveRange( gameStateHeading, scene, meta ); playerPane.appendChild( meta ); } if (scene) { playerPane.appendChild( scene ); } /* * WAYFARER_ACTION_SCREEN_V39 * * Action Mode is player-facing and exists independently of Author Mode. */ const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); if (actionScreen) { playerPane.appendChild( actionScreen ); } /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); if (presentationScreen) { playerPane.appendChild( presentationScreen ); } const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); if (travelControls) { playerPane.appendChild( travelControls ); } /* WAYFARER_PLAYER_HUD_V64 */ const persistentHud = document.querySelector( '[data-wayfarer-hud]' ); if (persistentHud) { playerPane.appendChild( persistentHud ); } // ------------------------------------------------------------ // Author header // ------------------------------------------------------------ const authorHeading = exactHeading( 'h2', 'Author Mode' ); if (authorHeading) { const builderLinkBlock = authorHeading.nextElementSibling; authorHeader.appendChild( authorHeading ); if ( builderLinkBlock && builderLinkBlock.tagName === 'P' ) { authorHeader.appendChild( builderLinkBlock ); } } // ------------------------------------------------------------ // Runtime controls // ------------------------------------------------------------ const tickPanel = document.getElementById( 'wayfarer-tick-test' ); if (tickPanel) { authorToolbar.appendChild( tickPanel ); } // ------------------------------------------------------------ // Author tabs // ------------------------------------------------------------ /* WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 */ const panelDefinitions = [ { key: 'state', label: 'State' }, { key: 'debug', label: 'Debug' } ]; function activateTab(key) { document .querySelectorAll( '.wayfarer-author-tab' ) .forEach( function (button) { const active = button.dataset .wayfarerAuthorTab === key; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); authorContent .querySelectorAll( '.wayfarer-author-tab-panel' ) .forEach( function (panel) { panel.classList.toggle( 'is-active', panel.dataset .wayfarerAuthorPanel === key ); } ); try { window.localStorage.setItem( 'wayfarerAuthorTab', key ); } catch (error) { // Storage is optional. } } for (const definition of panelDefinitions) { const panel = document.querySelector( '[data-wayfarer-author-panel="' + definition.key + '"]' ); if (!panel) { continue; } authorContent.appendChild( panel ); const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-author-tab'; button.dataset.wayfarerAuthorTab = definition.key; button.setAttribute( 'role', 'tab' ); button.textContent = definition.label; button.addEventListener( 'click', function () { activateTab( definition.key ); } ); tabBar.appendChild( button ); } let initialTab = 'state'; try { let savedTab = window.localStorage.getItem( 'wayfarerAuthorTab' ); if ( savedTab === 'region' ) { savedTab = 'state'; window.localStorage.setItem( 'wayfarerAuthorTab', 'state' ); } if ( savedTab && authorContent.querySelector( '[data-wayfarer-author-panel="' + savedTab + '"]' ) ) { initialTab = savedTab; } } catch (error) { // Storage is optional. } activateTab( initialTab ); // ------------------------------------------------------------ // Hide / Show Author Mode // WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 // ------------------------------------------------------------ if (canUseAuthorMode) { function renderAuthorToggle() { const collapsed = shell.classList.contains( 'author-collapsed' ); toggleAuthor.textContent = collapsed ? 'Show Author' : 'Hide Author'; toggleAuthor.setAttribute( 'aria-expanded', collapsed ? 'false' : 'true' ); } toggleAuthor.addEventListener( 'click', function () { shell.classList.toggle( 'author-collapsed' ); try { window.localStorage.setItem( 'wayfarerAuthorCollapsed', shell.classList.contains( 'author-collapsed' ) ? '1' : '0' ); } catch (error) { // Storage is optional. } renderAuthorToggle(); } ); try { if ( window.localStorage.getItem( 'wayfarerAuthorCollapsed' ) === '1' ) { shell.classList.add( 'author-collapsed' ); } } catch (error) { // Storage is optional. } renderAuthorToggle(); } else { /* * Player role is not merely a hidden Author interface. Keep the shell * permanently in player-only layout. */ shell.classList.add( 'author-collapsed' ); } // ------------------------------------------------------------ // Remove otherwise-empty top-level HRs left by the old document flow. // ------------------------------------------------------------ body .querySelectorAll( ':scope > hr' ) .forEach( function (rule) { rule.remove(); } ); })(); /* |-------------------------------------------------------------------------- | ENDING RESTART | WAYFARER_ENDING_RESTART_V63 |-------------------------------------------------------------------------- */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; const restartButton = document.querySelector( '[data-wayfarer-restart-game]' ); if ( !config || !restartButton ) { return; } let requestInFlight = false; restartButton.addEventListener( 'click', async function () { if (requestInFlight) { return; } if ( !window.confirm( 'Start this game over? Your current playthrough progress will be erased.' ) ) { return; } requestInFlight = true; restartButton.disabled = true; const originalText = restartButton.textContent; restartButton.textContent = 'Starting over…'; try { const response = await fetch( config.restartEndpoint || 'api/restart.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Restart endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not restart the game.' ); } document.body.classList.add( 'wayfarer-presentation-transitioning' ); window.location.href = 'index.php'; } catch (error) { requestInFlight = false; restartButton.disabled = false; restartButton.textContent = originalText; window.alert( error instanceof Error ? error.message : 'Could not restart the game.' ); } } ); })(); ================================================================================ FILE: _wayfarer_backups/v95-20260930-180850/play/includes/hud.php ================================================================================ HUD. */ require_once __DIR__ . '/../../includes/gameplay/hud.php'; if ( !isset($state) || !is_array($state) || !isset($pdo) || !($pdo instanceof PDO) ) { return; } $hudRegionId = (int)($state['navigation']['region_id'] ?? 0); if ($hudRegionId <= 0) { return; } $hudComponents = []; if ( isset($gameConfig['hud']['components']) && is_array($gameConfig['hud']['components']) ) { $hudComponents = array_values($gameConfig['hud']['components']); } else { $hudComponents = gameplayHudLegacyRegionComponents( $pdo, (int)$gameId ); } if (!$hudComponents) { return; } $hudRuntimeTargets = gameplayRuntimeStateTargets( $pdo, (int)$gameId ); $hudSources = gameplayHudNumericSources($hudRuntimeTargets); $hudRendered = []; foreach ($hudComponents as $component) { if (!is_array($component)) { continue; } $regions = array_values(array_unique(array_map( 'intval', (array)($component['regions'] ?? []) ))); if ($regions && !in_array($hudRegionId, $regions, true)) { continue; } $type = strtolower(trim((string)($component['type'] ?? 'number'))); if (!in_array($type, ['number', 'progress_bar'], true)) { continue; } $sourceKey = trim((string)($component['source'] ?? '')); $source = $hudSources[$sourceKey] ?? null; if (!is_array($source)) { continue; } $resolved = wayfarerRuntimeResolveConditionTarget( $state, $hudRegionId, (string)$source['scope'], 'trait', (string)$source['target_id'] ); if (empty($resolved['found']) || !is_numeric($resolved['value'] ?? null)) { continue; } $value = (float)$resolved['value']; $displayValue = floor($value) === $value ? (string)(int)$value : rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); $label = trim((string)($component['label'] ?? '')); if ($label === '') { $label = (string)($source['hud_label'] ?? $source['label'] ?? $sourceKey); } $row = [ 'id' => (string)($component['id'] ?? ''), 'type' => $type, 'scope' => (string)$source['scope'], 'source_id' => (string)$source['target_id'], 'label' => $label, 'value' => $value, 'display_value' => $displayValue, 'show_value' => $type === 'number' ? true : !empty($component['show_value']), 'min' => null, 'max' => null, 'percent' => null, ]; if ($type === 'progress_bar') { if (empty($source['bounded'])) { continue; } $min = (float)$source['min']; $max = (float)$source['max']; $percent = (($value - $min) / ($max - $min)) * 100; $percent = max(0, min(100, $percent)); $row['min'] = $min; $row['max'] = $max; $row['percent'] = $percent; } $hudRendered[] = $row; } if (!$hudRendered) { return; } $hudPresentationActive = !empty($state['runtime']['presentation']); $hudEndingActive = !empty($state['runtime']['ending']); ?> ================================================================================ FILE: _wayfarer_update_backups/v60-stormgate-before-20260925-212743-9f0020.json ================================================================================ { "created_at": "2026-09-25T21:27:43+00:00", "game_id": 1, "game": { "id": 1, "uuid": "5295c93f6b289525aa2424718d94907c", "name": "Stormgate", "slug": "stormgate", "description": "A dangerous storm is approaching. Reach the fortified city before it overtakes you.", "instructions": "You are a weary traveler looking for entry into the fortified city of Talon. The cost of entry; 20 flowers from a plant that grows wild in the outskirts of Talon.", "player_slots": 1, "config": "{\"traits\":[{\"id\":\"tr_ad270ad492c4323a\",\"name\":\"StormClock\",\"type\":\"number\",\"active\":true,\"notes\":\"Storm progress\",\"display\":{\"surfaces\":[]},\"min\":0,\"max\":100,\"initial\":10},{\"id\":\"tr_f00b336c519a0f4d\",\"name\":\"Water\",\"type\":\"number\",\"active\":true,\"notes\":\"The total amount of water you can carry\",\"display\":{\"surfaces\":[]},\"min\":0,\"max\":120,\"initial\":60}],\"starting_region_id\":1,\"setup\":{\"choices\":[{\"id\":\"choice_409128477f57\",\"prompt\":\"What is your proffesion?\",\"required\":true,\"options\":[{\"id\":\"option_3a3d1b6a0d40\",\"label\":\"An Archer\",\"description\":\"A skilled archer.\",\"effects\":[{\"target_scope\":\"entity:4\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"set\",\"value\":500,\"target_system_key\":\"quantity\"}]},{\"id\":\"option_0b88bcfc4ae7\",\"label\":\"A Noble\",\"description\":\"A noble of some minor privilege from a far off land.\",\"effects\":[{\"target_scope\":\"entity:4\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"set\",\"value\":1000,\"target_system_key\":\"quantity\"}]}]}]},\"player_traits\":[],\"endings\":[]}", "status": "draft", "created_at": "2026-09-21 12:28:28", "updated_at": "2026-09-21 12:28:28" }, "regions": [ { "id": 1, "game_id": 1, "name": "City Outskirts", "description": "The area surrounding the walled city.", "navigation_type": "linear", "config": "{\"traits\":[],\"tick_effects\":[{\"target_scope\":\"navigation\",\"target_type\":\"position\",\"target_id\":\"position\",\"operation\":\"add\",\"value\":10,\"branches\":[{\"conditions\":null,\"operation\":\"add\",\"value\":10}]},{\"target_scope\":\"game\",\"target_type\":\"trait\",\"target_id\":\"tr_ad270ad492c4323a\",\"operation\":\"add\",\"value\":10,\"branches\":[{\"conditions\":null,\"operation\":\"add\",\"value\":10}]},{\"target_scope\":\"game\",\"target_type\":\"trait\",\"target_id\":\"tr_f00b336c519a0f4d\",\"operation\":\"add\",\"value\":2,\"branches\":[{\"conditions\":{\"type\":\"group\",\"mode\":\"all\",\"children\":[{\"type\":\"condition\",\"scope\":\"game\",\"target_type\":\"trait\",\"target_id\":\"tr_ad270ad492c4323a\",\"operator\":\"greater_than_or_equal\",\"value\":50}]},\"operation\":\"subtract\",\"value\":5},{\"conditions\":null,\"operation\":\"add\",\"value\":2}]}]}", "status": "draft", "created_at": "2026-09-21 13:01:57", "updated_at": "2026-09-24 17:49:19" } ], "locations": [ { "id": 3, "region_id": 1, "name": "Stream", "description": "A shallow stream crossing the route to Stormgate.", "config": "{\"position\":150,\"traits\":[]}", "status": "draft", "created_at": "2026-09-21 13:17:06", "updated_at": "2026-09-21 13:17:06" }, { "id": 5, "region_id": 1, "name": "Orange Grove", "description": "An grove of orange trees.", "config": "{\"position\":100,\"traits\":[]}", "status": "active", "created_at": "2026-09-21 14:34:59", "updated_at": "2026-09-21 14:34:59" }, { "id": 6, "region_id": 1, "name": "Abandoned Hut", "description": "A small hunt of hasty design that looks as though it's been standing there for decades.", "config": "{\"position\":62,\"traits\":[]}", "status": "active", "created_at": "2026-09-21 14:36:07", "updated_at": "2026-09-21 14:36:07" }, { "id": 7, "region_id": 1, "name": "City Gate", "description": "From the position standing in front of the gate, the impossibly high walls of the city stretch out to the horizon in either direction.", "config": "{\"position\":1000,\"traits\":[]}", "status": "draft", "created_at": "2026-09-21 14:37:11", "updated_at": "2026-09-21 14:37:11" } ], "entities": [ { "id": 1, "game_id": 1, "entity_type": "item", "name": "Haper Flower", "description": "A small purple flower.", "config": "{\"traits\":[],\"system\":{\"quantity\":{\"initial\":0,\"max\":null},\"inventory_visibility\":\"always\"}}", "status": "active", "created_at": "2026-09-22 10:07:36", "updated_at": "2026-09-22 10:07:36" }, { "id": 2, "game_id": 1, "entity_type": "character", "name": "Mara", "description": "A middle aged woman who looks surprisingly spry.", "config": "{\"traits\":[{\"id\":\"tr_d6177dafaf3d8697\",\"name\":\"Health\",\"type\":\"number\",\"active\":true,\"notes\":\"\",\"display\":{\"surfaces\":[\"status\"]},\"min\":0,\"max\":100,\"initial\":80}],\"system\":{\"party_member\":0}}", "status": "active", "created_at": "2026-09-22 10:58:32", "updated_at": "2026-09-22 10:58:32" }, { "id": 3, "game_id": 1, "entity_type": "item", "name": "Paperclip", "description": "", "config": "{\"traits\":[],\"system\":{\"quantity\":{\"initial\":1,\"max\":1},\"inventory_visibility\":\"while_possessed\"}}", "status": "active", "created_at": "2026-09-24 18:29:15", "updated_at": "2026-09-24 18:29:15" }, { "id": 4, "game_id": 1, "entity_type": "item", "name": "Coin", "description": "", "config": "{\"traits\":[],\"system\":{\"quantity\":{\"initial\":0,\"max\":20000},\"inventory_visibility\":\"always\"}}", "status": "active", "created_at": "2026-09-25 12:50:59", "updated_at": "2026-09-25 12:50:59" } ], "storylets": [], "game_users": [ { "id": 1, "game_id": 1, "user_id": 1, "role": "author", "state": "{\"schema_version\":1,\"game\":{\"traits\":{\"tr_ad270ad492c4323a\":10,\"tr_f00b336c519a0f4d\":60}},\"navigation\":{\"region_id\":1,\"position\":0},\"regions\":{\"1\":{\"traits\":[]}},\"runtime\":{\"tick\":0},\"entities\":{\"4\":{\"traits\":{\"sys_item_quantity\":500},\"discovered\":true},\"1\":{\"traits\":{\"sys_item_quantity\":0},\"discovered\":false},\"2\":{\"traits\":{\"sys_character_party_member\":0,\"tr_d6177dafaf3d8697\":80}},\"3\":{\"traits\":{\"sys_item_quantity\":1},\"discovered\":true}},\"setup\":{\"player_names\":[\"Polly\"],\"choices\":{\"choice_409128477f57\":\"option_3a3d1b6a0d40\"}},\"players\":[{\"slot\":0,\"name\":\"Polly\",\"traits\":[]}]}", "created_at": "2026-09-21 17:36:52", "updated_at": "2026-09-25 14:11:55" } ] } ================================================================================ FILE: _wayfarer_update_backups/v60-stormgate-before-20260925-213003-000a7d.json ================================================================================ { "created_at": "2026-09-25T21:30:03+00:00", "game_id": 1, "game": { "id": 1, "uuid": "5295c93f6b289525aa2424718d94907c", "name": "Stormgate", "slug": "stormgate", "description": "A dangerous storm is approaching. Reach the fortified city before it overtakes you.", "instructions": "You are a weary traveler looking for entry into the fortified city of Talon. The cost of entry; 20 flowers from a plant that grows wild in the outskirts of Talon.", "player_slots": 1, "config": "{\"traits\":[{\"id\":\"tr_ad270ad492c4323a\",\"name\":\"StormClock\",\"type\":\"number\",\"active\":true,\"notes\":\"Storm progress\",\"display\":{\"surfaces\":[]},\"min\":0,\"max\":100,\"initial\":10},{\"id\":\"tr_f00b336c519a0f4d\",\"name\":\"Water\",\"type\":\"number\",\"active\":true,\"notes\":\"The total amount of water you can carry\",\"display\":{\"surfaces\":[]},\"min\":0,\"max\":120,\"initial\":60}],\"starting_region_id\":1,\"setup\":{\"choices\":[{\"id\":\"choice_409128477f57\",\"prompt\":\"What is your proffesion?\",\"required\":true,\"options\":[{\"id\":\"option_3a3d1b6a0d40\",\"label\":\"An Archer\",\"description\":\"A skilled archer.\",\"effects\":[{\"target_scope\":\"entity:4\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"set\",\"value\":500,\"target_system_key\":\"quantity\"}]},{\"id\":\"option_0b88bcfc4ae7\",\"label\":\"A Noble\",\"description\":\"A noble of some minor privilege from a far off land.\",\"effects\":[{\"target_scope\":\"entity:4\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"set\",\"value\":1000,\"target_system_key\":\"quantity\"}]}]}]},\"player_traits\":[],\"endings\":[]}", "status": "draft", "created_at": "2026-09-21 12:28:28", "updated_at": "2026-09-21 12:28:28" }, "regions": [ { "id": 1, "game_id": 1, "name": "City Outskirts", "description": "The area surrounding the walled city.", "navigation_type": "linear", "config": "{\"traits\":[],\"tick_effects\":[{\"target_scope\":\"navigation\",\"target_type\":\"position\",\"target_id\":\"position\",\"operation\":\"add\",\"value\":10,\"branches\":[{\"conditions\":null,\"operation\":\"add\",\"value\":10}]},{\"target_scope\":\"game\",\"target_type\":\"trait\",\"target_id\":\"tr_ad270ad492c4323a\",\"operation\":\"add\",\"value\":10,\"branches\":[{\"conditions\":null,\"operation\":\"add\",\"value\":10}]},{\"target_scope\":\"game\",\"target_type\":\"trait\",\"target_id\":\"tr_f00b336c519a0f4d\",\"operation\":\"add\",\"value\":2,\"branches\":[{\"conditions\":{\"type\":\"group\",\"mode\":\"all\",\"children\":[{\"type\":\"condition\",\"scope\":\"game\",\"target_type\":\"trait\",\"target_id\":\"tr_ad270ad492c4323a\",\"operator\":\"greater_than_or_equal\",\"value\":50}]},\"operation\":\"subtract\",\"value\":5},{\"conditions\":null,\"operation\":\"add\",\"value\":2}]}]}", "status": "draft", "created_at": "2026-09-21 13:01:57", "updated_at": "2026-09-24 17:49:19" } ], "locations": [ { "id": 3, "region_id": 1, "name": "Stream", "description": "A shallow stream crossing the route to Stormgate.", "config": "{\"position\":150,\"traits\":[]}", "status": "draft", "created_at": "2026-09-21 13:17:06", "updated_at": "2026-09-21 13:17:06" }, { "id": 5, "region_id": 1, "name": "Orange Grove", "description": "An grove of orange trees.", "config": "{\"position\":100,\"traits\":[]}", "status": "active", "created_at": "2026-09-21 14:34:59", "updated_at": "2026-09-21 14:34:59" }, { "id": 6, "region_id": 1, "name": "Abandoned Hut", "description": "A small hunt of hasty design that looks as though it's been standing there for decades.", "config": "{\"position\":62,\"traits\":[]}", "status": "active", "created_at": "2026-09-21 14:36:07", "updated_at": "2026-09-21 14:36:07" }, { "id": 7, "region_id": 1, "name": "City Gate", "description": "From the position standing in front of the gate, the impossibly high walls of the city stretch out to the horizon in either direction.", "config": "{\"position\":1000,\"traits\":[]}", "status": "draft", "created_at": "2026-09-21 14:37:11", "updated_at": "2026-09-21 14:37:11" } ], "entities": [ { "id": 1, "game_id": 1, "entity_type": "item", "name": "Haper Flower", "description": "A small purple flower.", "config": "{\"traits\":[],\"system\":{\"quantity\":{\"initial\":0,\"max\":null},\"inventory_visibility\":\"always\"}}", "status": "active", "created_at": "2026-09-22 10:07:36", "updated_at": "2026-09-22 10:07:36" }, { "id": 2, "game_id": 1, "entity_type": "character", "name": "Mara", "description": "A middle aged woman who looks surprisingly spry.", "config": "{\"traits\":[{\"id\":\"tr_d6177dafaf3d8697\",\"name\":\"Health\",\"type\":\"number\",\"active\":true,\"notes\":\"\",\"display\":{\"surfaces\":[\"status\"]},\"min\":0,\"max\":100,\"initial\":80}],\"system\":{\"party_member\":0}}", "status": "active", "created_at": "2026-09-22 10:58:32", "updated_at": "2026-09-22 10:58:32" }, { "id": 3, "game_id": 1, "entity_type": "item", "name": "Paperclip", "description": "", "config": "{\"traits\":[],\"system\":{\"quantity\":{\"initial\":1,\"max\":1},\"inventory_visibility\":\"while_possessed\"}}", "status": "active", "created_at": "2026-09-24 18:29:15", "updated_at": "2026-09-24 18:29:15" }, { "id": 4, "game_id": 1, "entity_type": "item", "name": "Coin", "description": "", "config": "{\"traits\":[],\"system\":{\"quantity\":{\"initial\":0,\"max\":20000},\"inventory_visibility\":\"always\"}}", "status": "active", "created_at": "2026-09-25 12:50:59", "updated_at": "2026-09-25 12:50:59" } ], "storylets": [], "game_users": [ { "id": 1, "game_id": 1, "user_id": 1, "role": "author", "state": "{\"schema_version\":1,\"game\":{\"traits\":{\"tr_ad270ad492c4323a\":10,\"tr_f00b336c519a0f4d\":60}},\"navigation\":{\"region_id\":1,\"position\":0},\"regions\":{\"1\":{\"traits\":[]}},\"runtime\":{\"tick\":0},\"entities\":{\"4\":{\"traits\":{\"sys_item_quantity\":500},\"discovered\":true},\"1\":{\"traits\":{\"sys_item_quantity\":0},\"discovered\":false},\"2\":{\"traits\":{\"sys_character_party_member\":0,\"tr_d6177dafaf3d8697\":80}},\"3\":{\"traits\":{\"sys_item_quantity\":1},\"discovered\":true}},\"setup\":{\"player_names\":[\"Polly\"],\"choices\":{\"choice_409128477f57\":\"option_3a3d1b6a0d40\"}},\"players\":[{\"slot\":0,\"name\":\"Polly\",\"traits\":[]}]}", "created_at": "2026-09-21 17:36:52", "updated_at": "2026-09-25 14:11:55" } ] } ================================================================================ FILE: _wayfarer_update_backups/v60-stormgate-before-20260925-222238-bf173d.json ================================================================================ { "created_at": "2026-09-25T22:22:38+00:00", "game_id": 1, "game": { "id": 1, "uuid": "5295c93f6b289525aa2424718d94907c", "name": "Stormgate", "slug": "stormgate", "description": "A dangerous storm is approaching. Reach the fortified city before it overtakes you.", "instructions": "You are a weary traveler looking for entry into the fortified city of Talon. The cost of entry; 20 flowers from a plant that grows wild in the outskirts of Talon.", "player_slots": 1, "config": "{\"traits\":[{\"id\":\"tr_ad270ad492c4323a\",\"name\":\"StormClock\",\"type\":\"number\",\"active\":true,\"notes\":\"Storm progress\",\"display\":{\"surfaces\":[]},\"min\":0,\"max\":100,\"initial\":10},{\"id\":\"tr_f00b336c519a0f4d\",\"name\":\"Water\",\"type\":\"number\",\"active\":true,\"notes\":\"The total amount of water you can carry\",\"display\":{\"surfaces\":[]},\"min\":0,\"max\":120,\"initial\":60}],\"starting_region_id\":1,\"setup\":{\"choices\":[{\"id\":\"choice_409128477f57\",\"prompt\":\"What is your proffesion?\",\"required\":true,\"options\":[{\"id\":\"option_3a3d1b6a0d40\",\"label\":\"An Archer\",\"description\":\"A skilled archer.\",\"effects\":[{\"target_scope\":\"entity:4\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"set\",\"value\":500,\"target_system_key\":\"quantity\"}]},{\"id\":\"option_0b88bcfc4ae7\",\"label\":\"A Noble\",\"description\":\"A noble of some minor privilege from a far off land.\",\"effects\":[{\"target_scope\":\"entity:4\",\"target_type\":\"trait\",\"target_id\":\"sys_item_quantity\",\"operation\":\"set\",\"value\":1000,\"target_system_key\":\"quantity\"}]}]}]},\"player_traits\":[],\"endings\":[]}", "status": "draft", "created_at": "2026-09-21 12:28:28", "updated_at": "2026-09-21 12:28:28" }, "regions": [ { "id": 1, "game_id": 1, "name": "City Outskirts", "description": "The area surrounding the walled city.", "navigation_type": "linear", "config": "{\"traits\":[],\"tick_effects\":[{\"target_scope\":\"navigation\",\"target_type\":\"position\",\"target_id\":\"position\",\"operation\":\"add\",\"value\":10,\"branches\":[{\"conditions\":null,\"operation\":\"add\",\"value\":10}]},{\"target_scope\":\"game\",\"target_type\":\"trait\",\"target_id\":\"tr_ad270ad492c4323a\",\"operation\":\"add\",\"value\":10,\"branches\":[{\"conditions\":null,\"operation\":\"add\",\"value\":10}]},{\"target_scope\":\"game\",\"target_type\":\"trait\",\"target_id\":\"tr_f00b336c519a0f4d\",\"operation\":\"add\",\"value\":2,\"branches\":[{\"conditions\":{\"type\":\"group\",\"mode\":\"all\",\"children\":[{\"type\":\"condition\",\"scope\":\"game\",\"target_type\":\"trait\",\"target_id\":\"tr_ad270ad492c4323a\",\"operator\":\"greater_than_or_equal\",\"value\":50}]},\"operation\":\"subtract\",\"value\":5},{\"conditions\":null,\"operation\":\"add\",\"value\":2}]}]}", "status": "draft", "created_at": "2026-09-21 13:01:57", "updated_at": "2026-09-24 17:49:19" } ], "locations": [ { "id": 3, "region_id": 1, "name": "Stream", "description": "A shallow stream crossing the route to Stormgate.", "config": "{\"position\":150,\"traits\":[]}", "status": "draft", "created_at": "2026-09-21 13:17:06", "updated_at": "2026-09-21 13:17:06" }, { "id": 5, "region_id": 1, "name": "Orange Grove", "description": "An grove of orange trees.", "config": "{\"position\":100,\"traits\":[]}", "status": "active", "created_at": "2026-09-21 14:34:59", "updated_at": "2026-09-21 14:34:59" }, { "id": 6, "region_id": 1, "name": "Abandoned Hut", "description": "A small hunt of hasty design that looks as though it's been standing there for decades.", "config": "{\"position\":62,\"traits\":[]}", "status": "active", "created_at": "2026-09-21 14:36:07", "updated_at": "2026-09-21 14:36:07" }, { "id": 7, "region_id": 1, "name": "City Gate", "description": "From the position standing in front of the gate, the impossibly high walls of the city stretch out to the horizon in either direction.", "config": "{\"position\":1000,\"traits\":[]}", "status": "draft", "created_at": "2026-09-21 14:37:11", "updated_at": "2026-09-21 14:37:11" } ], "entities": [ { "id": 1, "game_id": 1, "entity_type": "item", "name": "Haper Flower", "description": "A small purple flower.", "config": "{\"traits\":[],\"system\":{\"quantity\":{\"initial\":0,\"max\":null},\"inventory_visibility\":\"always\"}}", "status": "active", "created_at": "2026-09-22 10:07:36", "updated_at": "2026-09-22 10:07:36" }, { "id": 2, "game_id": 1, "entity_type": "character", "name": "Mara", "description": "A middle aged woman who looks surprisingly spry.", "config": "{\"traits\":[{\"id\":\"tr_d6177dafaf3d8697\",\"name\":\"Health\",\"type\":\"number\",\"active\":true,\"notes\":\"\",\"display\":{\"surfaces\":[\"status\"]},\"min\":0,\"max\":100,\"initial\":80}],\"system\":{\"party_member\":0}}", "status": "active", "created_at": "2026-09-22 10:58:32", "updated_at": "2026-09-22 10:58:32" }, { "id": 3, "game_id": 1, "entity_type": "item", "name": "Paperclip", "description": "", "config": "{\"traits\":[],\"system\":{\"quantity\":{\"initial\":1,\"max\":1},\"inventory_visibility\":\"while_possessed\"}}", "status": "active", "created_at": "2026-09-24 18:29:15", "updated_at": "2026-09-24 18:29:15" }, { "id": 4, "game_id": 1, "entity_type": "item", "name": "Coin", "description": "", "config": "{\"traits\":[],\"system\":{\"quantity\":{\"initial\":0,\"max\":20000},\"inventory_visibility\":\"always\"}}", "status": "active", "created_at": "2026-09-25 12:50:59", "updated_at": "2026-09-25 12:50:59" } ], "storylets": [], "game_users": [ { "id": 1, "game_id": 1, "user_id": 1, "role": "author", "state": "{\"schema_version\":1,\"game\":{\"traits\":{\"tr_ad270ad492c4323a\":10,\"tr_f00b336c519a0f4d\":60}},\"navigation\":{\"region_id\":1,\"position\":0},\"regions\":{\"1\":{\"traits\":[]}},\"runtime\":{\"tick\":0},\"entities\":{\"4\":{\"traits\":{\"sys_item_quantity\":500},\"discovered\":true},\"1\":{\"traits\":{\"sys_item_quantity\":0},\"discovered\":false},\"2\":{\"traits\":{\"sys_character_party_member\":0,\"tr_d6177dafaf3d8697\":80}},\"3\":{\"traits\":{\"sys_item_quantity\":1},\"discovered\":true}},\"setup\":{\"player_names\":[\"Polly\"],\"choices\":{\"choice_409128477f57\":\"option_3a3d1b6a0d40\"}},\"players\":[{\"slot\":0,\"name\":\"Polly\",\"traits\":[]}]}", "created_at": "2026-09-21 17:36:52", "updated_at": "2026-09-25 14:11:55" } ] } ================================================================================ FILE: admin/builder-refactored.php ================================================================================ prepare(" SELECT id, name, config FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; /* WAYFARER_STORYLET_BUILDER_V56 * A + New Branch Storylet link can preselect its parent and choose a sensible * sibling order without making parent_storylet_id part of the generic request. */ if ( $type === 'storylets' && !$id && !empty($_GET['parent_storylet_id']) ) { $requestedParentId = (int)$_GET['parent_storylet_id']; $parentStmt = $pdo->prepare( " SELECT id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $parentStmt->execute([ $requestedParentId, $gameId, ]); if ($parentStmt->fetchColumn()) { $record['parent_storylet_id'] = $requestedParentId; $orderStmt = $pdo->prepare( " SELECT COALESCE(MAX(branch_sort_order), 0) FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? " ); $orderStmt->execute([ $gameId, $requestedParentId, ]); $record['branch_sort_order'] = (int)$orderStmt->fetchColumn() + 10; } } // ================================================================ // SAVE / DELETE / DUPLICATE // WAYFARER_STORYLET_DUPLICATE_V83 // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'duplicate') { $duplicateId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; if ( ($definition['table'] ?? '') !== 'wf_storylets' ) { $error = 'Only Storylets can be duplicated.'; } else { $duplicateResult = duplicateStoryletRecord( $pdo, $gameId, $duplicateId ); $error = $duplicateResult['error']; if ($duplicateResult['duplicated']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) . '&id=' . (int)$duplicateResult['id'] . '&tab=basics' . '&duplicated=1' ); exit; } } } elseif ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { /* WAYFARER_EXCHANGES_V104 */ if (($definition['table'] ?? '') === 'wf_exchanges') { $saveResult = saveExchangeBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); } $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { /* * Media Library associations are applied only after the authored * object has saved successfully and therefore has a stable ID. */ $savedRecordId = (int)$saveResult['id']; $mediaResult = applyBuilderMediaSelections( $pdo, $gameId, $definition['table'], $savedRecordId, $_POST, $_FILES ); if (!$mediaResult['saved']) { /* * The authored object itself has already saved. Keep the * editor on that record and report the media failure * accurately rather than pretending the object save failed. */ $id = $savedRecordId; $record['id'] = $savedRecordId; $error = 'Record saved, but media was not changed: ' . $mediaResult['error']; } else { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) . '&id=' . (int)$saveResult['id'] . '&tab=' . rawurlencode( (string)($_POST['active_tab'] ?? 'basics') ) ); exit; } } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder

================================================================================ FILE: admin/games/edit.php ================================================================================ null, 'name' => '', 'slug' => '', 'description' => '', 'instructions' => '', 'player_slots' => 0, 'config' => null, 'status' => 'draft' ]; $traits = []; $playerTraits = []; $config = []; /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ $gameTickEffects = []; $gameplayTickEffects = []; $startingChoices = []; $startingChoicesEditor = []; $startingChoiceItemTargets = []; /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 */ $gameEndings = []; $gameEndingsEditor = []; /* WAYFARER_GAME_TRAVEL_MODIFIERS_V73 */ $gameTravelModifiers = []; $gameTravelModifiersEditor = []; /* WAYFARER_GAME_HUD_STATE_V94 */ $gameHudComponents = []; $gameHudComponentsEditor = []; $gameHudSources = []; $error = ''; // ================================================================ // LOAD EXISTING GAME // ================================================================ if (isset($_GET['id'])) { $stmt = $pdo->prepare(" SELECT * FROM wf_games WHERE id = ? "); $stmt->execute([(int)$_GET['id']]); $existingGame = $stmt->fetch(); if (!$existingGame) { die('Game not found.'); } $game = array_merge($game, $existingGame); if (!empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } if ( isset($config['traits']) && is_array($config['traits']) ) { $traits = $config['traits']; } /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ if ( isset($config['player_traits']) && is_array($config['player_traits']) ) { $playerTraits = array_values( $config['player_traits'] ); } /* WAYFARER_LOAD_GAME_TICK_EFFECTS_V33 */ if ( isset($config['tick_effects']) && is_array($config['tick_effects']) ) { $gameTickEffects = array_values( $config['tick_effects'] ); } if ( isset($config['setup']['choices']) && is_array($config['setup']['choices']) ) { $startingChoices = array_values( $config['setup']['choices'] ); } /* WAYFARER_GAME_ENDINGS_V45 */ if ( isset($config['endings']) && is_array($config['endings']) ) { $gameEndings = array_values( $config['endings'] ); } /* WAYFARER_LOAD_GAME_TRAVEL_MODIFIERS_V73 */ if ( isset($config['travel']['modifiers']) && is_array($config['travel']['modifiers']) ) { $gameTravelModifiers = array_values( $config['travel']['modifiers'] ); } /* WAYFARER_LOAD_GAME_HUD_V94 */ if ( isset($config['hud']['components']) && is_array($config['hud']['components']) ) { $gameHudComponents = array_values($config['hud']['components']); } else { $gameHudComponents = gameplayHudLegacyRegionComponents( $pdo, (int)$game['id'] ); } } $gameplayTickEffects = $gameTickEffects; $startingChoicesEditor = $startingChoices; $gameEndingsEditor = $gameEndings; $gameTravelModifiersEditor = $gameTravelModifiers; $gameHudComponentsEditor = $gameHudComponents; // ================================================================ // SAVE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $game['id'] = !empty($_POST['id']) ? (int)$_POST['id'] : null; $game['name'] = trim($_POST['name'] ?? ''); $game['slug'] = trim($_POST['slug'] ?? ''); $game['description'] = trim($_POST['description'] ?? ''); $game['instructions'] = trim($_POST['instructions'] ?? ''); $game['player_slots'] = max(0, (int)($_POST['player_slots'] ?? 0)); $game['status'] = $_POST['status'] ?? 'draft'; // ------------------------------------------------------------ // BUILD GAME + PLAYER TRAIT DEFINITIONS // WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 // ------------------------------------------------------------ $traitResult = buildTraitsFromPost( $_POST['traits'] ?? [], ['status'] ); $traits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } $playerTraitResult = buildTraitsFromPost( $_POST['player_traits'] ?? [], ['status'] ); $playerTraits = $playerTraitResult['traits']; if ( $error === '' && $playerTraitResult['error'] !== '' ) { $error = 'Player Traits: ' . $playerTraitResult['error']; } /* WAYFARER_GAME_SETTINGS_UNIVERSAL_TARGETS_V56 */ $gameplayRuntimeTargets = gameplayRuntimeStateTargets( $pdo, (int)($game['id'] ?? 0), $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ) ); /* WAYFARER_BUILD_GAME_HUD_V94 */ $gameHudSources = gameplayHudNumericSources($gameplayRuntimeTargets); $postedGameHudComponents = isset($_POST['hud_components']) && is_array($_POST['hud_components']) ? array_values($_POST['hud_components']) : []; $gameHudComponentsEditor = $postedGameHudComponents; $gameHudRegionIds = []; if ((int)($game['id'] ?? 0) > 0) { $gameHudRegionStmt = $pdo->prepare("SELECT id FROM wf_regions WHERE game_id = ?"); $gameHudRegionStmt->execute([(int)$game['id']]); $gameHudRegionIds = array_map('intval', $gameHudRegionStmt->fetchAll(PDO::FETCH_COLUMN)); } $gameHudResult = gameplayBuildGameHudFromPost( $postedGameHudComponents, $gameHudSources, $gameHudRegionIds ); $gameHudComponentsEditor = $gameHudResult['editor_components'] ?? $postedGameHudComponents; if ($error === '' && ($gameHudResult['error'] ?? '') !== '') { $error = (string)$gameHudResult['error']; } if (($gameHudResult['error'] ?? '') === '') { $gameHudComponents = $gameHudResult['components'] ?? []; } /* WAYFARER_BUILD_GAME_TRAVEL_MODIFIERS_V73 */ $postedGameTravelModifiers = isset($_POST['travel_modifiers']) && is_array($_POST['travel_modifiers']) ? array_values( $_POST['travel_modifiers'] ) : []; $gameTravelModifiersEditor = $postedGameTravelModifiers; /* * Build the editor structure even if another Game Settings section already * has a validation error. This keeps selected Travel Conditions intact on the * failed POST. Only the first validation error becomes the page error. */ $gameTravelModifierResult = wayfarerBuildGameTravelModifiersFromPost( $postedGameTravelModifiers, $gameplayRuntimeTargets ); $gameTravelModifiersEditor = $gameTravelModifierResult[ 'editor_modifiers' ]; if ( $error === '' && $gameTravelModifierResult['error'] !== '' ) { $error = $gameTravelModifierResult['error']; } if ( $gameTravelModifierResult['error'] === '' ) { $gameTravelModifiers = $gameTravelModifierResult[ 'modifiers' ]; } /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 */ $startingChoiceItemTargets = wayfarerStartingChoiceItemQuantityTargets( $pdo, (int)($game['id'] ?? 0) ); /* WAYFARER_BUILD_STARTING_CHOICES_V36 */ /* WAYFARER_STARTING_CHOICES_CANONICAL_EMPTY_V36_1 */ $startingChoicesSubmitted = isset($_POST['setup_choices_present']) || array_key_exists( 'setup_choices', $_POST ); $postedStartingChoices = isset($_POST['setup_choices']) && is_array($_POST['setup_choices']) ? array_values( $_POST['setup_choices'] ) : []; $startingChoicesEditor = $postedStartingChoices; if ($error === '') { $startingChoiceResult = wayfarerBuildStartingChoicesFromPost( $postedStartingChoices, $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ), $startingChoiceItemTargets, $gameplayRuntimeTargets ); if ( $startingChoiceResult['error'] !== '' ) { $error = $startingChoiceResult['error']; } else { $startingChoices = $startingChoiceResult['choices']; $startingChoicesEditor = $startingChoices; } } /* WAYFARER_BUILD_GAME_ENDINGS_V45 */ $postedGameEndings = isset($_POST['endings']) && is_array($_POST['endings']) ? array_values( $_POST['endings'] ) : []; $gameEndingsEditor = $postedGameEndings; if ($error === '') { $endingResult = wayfarerBuildGameEndingsFromPost( $pdo, (int)($game['id'] ?? 0), $postedGameEndings, $traits ); if ( $endingResult['error'] !== '' ) { $error = $endingResult['error']; } else { $gameEndings = $endingResult['endings']; $gameEndingsEditor = $gameEndings; } } /* WAYFARER_BUILD_GAME_TICK_EFFECTS_V33 */ $postedGameTickEffects = isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ? array_values( $_POST['tick_effects'] ) : []; $gameplayTickEffects = $postedGameTickEffects; if ($error === '') { $gameTickEffectResult = gameplayBuildTickEffectsFromPost( $gameplayRuntimeTargets, $postedGameTickEffects, $gameplayRuntimeTargets ); if ( $gameTickEffectResult['error'] !== '' ) { $error = $gameTickEffectResult['error']; } else { $gameTickEffects = $gameTickEffectResult['effects']; $gameplayTickEffects = $gameTickEffects; } } // ------------------------------------------------------------ // VALIDATE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['name'] === '') { $error = 'Game name is required.'; } elseif ($game['slug'] === '') { $error = 'Slug is required.'; } } // ------------------------------------------------------------ // BUILD CONFIG JSON // ------------------------------------------------------------ if ($error === '') { /* * Preserve any other configuration already stored for the game. * Traits own only the "traits" section of config. */ if (empty($config) && !empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } $startingRegionId = !empty($_POST['starting_region_id']) ? (int)$_POST['starting_region_id'] : null; if ($startingRegionId !== null) { $config['starting_region_id'] = $startingRegionId; } else { unset($config['starting_region_id']); } /* WAYFARER_GAME_TIME_SETTINGS_SAVE_V61 */ $timeMode = strtolower(trim((string)($_POST['time_tracking'] ?? 'none'))); if (!in_array($timeMode, ['none', 'days', 'hours'], true)) { $timeMode = 'none'; } if ($timeMode === 'none') { unset($config['time']); } else { $startingDay = max(1, (int)($_POST['time_starting_day'] ?? 1)); if ($timeMode === 'days') { $config['time'] = [ 'mode' => 'days', 'starting_day' => $startingDay, 'ticks_per_day' => max(1, (int)($_POST['time_ticks_per_day'] ?? 1)), ]; } else { $hoursPerDay = max(1, (int)($_POST['time_hours_per_day'] ?? 24)); $startingHour = max(0, (int)($_POST['time_starting_hour'] ?? 8)); if ($startingHour >= $hoursPerDay) { $startingHour = $hoursPerDay - 1; } $config['time'] = [ 'mode' => 'hours', 'starting_day' => $startingDay, 'starting_hour' => $startingHour, 'ticks_per_hour' => max(1, (int)($_POST['time_ticks_per_hour'] ?? 1)), 'hours_per_day' => $hoursPerDay, ]; } } $config['traits'] = array_values( $traits ); /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ $config['player_traits'] = array_values( $playerTraits ); /* WAYFARER_SAVE_GAME_TRAVEL_MODIFIERS_V73 */ if ($gameTravelModifiers) { if ( !isset($config['travel']) || !is_array($config['travel']) ) { $config['travel'] = []; } $config['travel']['modifiers'] = array_values( $gameTravelModifiers ); } else { if ( isset($config['travel']) && is_array($config['travel']) ) { unset( $config['travel']['modifiers'] ); if ($config['travel'] === []) { unset($config['travel']); } } } /* WAYFARER_SAVE_GAME_HUD_V94 */ if ($gameHudComponents) { $config['hud'] = ['components' => array_values($gameHudComponents)]; } else { unset($config['hud']); } /* WAYFARER_SAVE_GAME_TICK_EFFECTS_V33 */ if ($gameTickEffects) { $config['tick_effects'] = array_values( $gameTickEffects ); } else { unset( $config['tick_effects'] ); } /* WAYFARER_SAVE_STARTING_CHOICES_V36 */ /* * WAYFARER_STARTING_CHOICES_CANONICAL_EMPTY_V36_1 * * If the Setup editor participated in this submission, its current * collection is the canonical collection. In particular, deleting the * final card must persist an explicit empty array rather than relying * on the absence of setup_choices in POST. */ if ($startingChoicesSubmitted) { if ( !isset($config['setup']) || !is_array($config['setup']) ) { $config['setup'] = []; } $config['setup']['choices'] = array_values( $startingChoices ); } /* WAYFARER_SAVE_GAME_ENDINGS_V45 */ $config['endings'] = array_values( $gameEndings ); $game['config'] = json_encode( $config, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ($game['config'] === false) { $error = 'Unable to encode game configuration.'; } } // ------------------------------------------------------------ // WRITE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['id']) { $stmt = $pdo->prepare(" UPDATE wf_games SET name = ?, slug = ?, description = ?, instructions = ?, player_slots = ?, config = ?, status = ? WHERE id = ? "); $stmt->execute([ $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'], $game['id'] ]); } else { $uuid = bin2hex(random_bytes(16)); $stmt = $pdo->prepare(" INSERT INTO wf_games ( uuid, name, slug, description, instructions, player_slots, config, status ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $uuid, $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'] ]); $game['id'] = (int)$pdo->lastInsertId(); } /* * WAYFARER_STAY_ON_GAME_SETTINGS_V38 * * Successful saves remain on the same Game Settings record and reopen * the tab the author was using. */ $savedGameTab = (string)( $_POST['active_tab'] ?? 'basics' ); if ( !in_array( $savedGameTab, [ 'basics', 'setup', 'traits', 'gameplay', 'travel', 'hud', 'endings', ], true ) ) { $savedGameTab = 'basics'; } header( 'Location: edit.php?id=' . (int)$game['id'] . '&tab=' . rawurlencode( $savedGameTab ) ); exit; } } // ================================================================ // LOAD REGIONS FOR STARTING REGION // ================================================================ $regions = []; if (!empty($game['id'])) { $regionStmt = $pdo->prepare(" SELECT id, name FROM wf_regions WHERE game_id = ? ORDER BY name "); $regionStmt->execute([ $game['id'] ]); $regions = $regionStmt->fetchAll(); } /* WAYFARER_GAME_SETTINGS_UNIVERSAL_TARGETS_RENDER_V56 */ $gameplayRuntimeTargets = gameplayRuntimeStateTargets( $pdo, (int)($game['id'] ?? 0), $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ) ); /* WAYFARER_GAME_HUD_RENDER_DATA_V94 */ $gameHudSources = gameplayHudNumericSources($gameplayRuntimeTargets); /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_RENDER_V54 */ $startingChoiceItemTargets = wayfarerStartingChoiceItemQuantityTargets( $pdo, (int)($game['id'] ?? 0) ); ?> <?= $game['id'] ? 'Edit Game' : 'New Game' ?> - Wayfarer


Example: stormgate

================================================================================ FILE: admin/games/index.php ================================================================================ query(" SELECT id, name, slug, status, updated_at FROM wf_games ORDER BY name "); $games = $stmt->fetchAll(); ?> Wayfarer

Wayfarer

Games

+ New Game

No games have been created yet.

Name Slug Status Last Updated Actions
Open Builder | Game Settings
================================================================================ FILE: assets/css/admin.css ================================================================================ /* |-------------------------------------------------------------------------- | Wayfarer Admin Theme |-------------------------------------------------------------------------- | Shared visual language for Wayfarer administrative pages. */ :root { --wf-border: #d8dbe2; --wf-border-strong: #b8bdc7; --wf-surface: #ffffff; --wf-surface-soft: #f6f7f9; --wf-page: #f3f4f6; --wf-text: #20242a; --wf-muted: #667085; --wf-accent: #2f5ea8; --wf-accent-hover: #244b88; --wf-accent-soft: #edf3fc; --wf-success-soft: #eef8f1; --wf-success-text: #28603a; --wf-radius: 8px; --wf-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); } .wayfarer-admin-page { box-sizing: border-box; margin: 0; padding: 28px; background: var(--wf-page); color: var(--wf-text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; font-size: 15px; line-height: 1.45; } .wayfarer-admin-page *, .wayfarer-admin-page *::before, .wayfarer-admin-page *::after { box-sizing: border-box; } .wayfarer-admin-page > * { max-width: 1180px; margin-left: auto; margin-right: auto; } .wayfarer-admin-page > h1 { margin-top: 0; margin-bottom: 2px; font-size: 2rem; line-height: 1.15; } .wayfarer-admin-page > h2 { margin-top: 0; margin-bottom: 20px; color: var(--wf-muted); font-size: 1rem; font-weight: 500; } .wayfarer-admin-page a { color: var(--wf-accent); } .wayfarer-admin-page a:hover { color: var(--wf-accent-hover); } /* ------------------------------------------------------------------ GAMES INDEX ------------------------------------------------------------------ */ .wayfarer-games-page > p { margin-top: 0; margin-bottom: 18px; } .admin-primary-action { display: inline-block; padding: 9px 14px; border: 1px solid #214b86; border-radius: 6px; background: var(--wf-accent); color: #ffffff !important; text-decoration: none; font-weight: 600; box-shadow: var(--wf-shadow); } .admin-primary-action:hover { background: var(--wf-accent-hover); color: #ffffff !important; } .admin-table { width: 100%; overflow: hidden; border: 1px solid var(--wf-border); border-collapse: separate; border-spacing: 0; border-radius: var(--wf-radius); background: var(--wf-surface); box-shadow: var(--wf-shadow); } .admin-table th, .admin-table td { padding: 12px 14px; border: 0; border-bottom: 1px solid var(--wf-border); text-align: left; vertical-align: middle; } .admin-table th { background: var(--wf-surface-soft); color: #404650; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.045em; text-transform: uppercase; } .admin-table tr:last-child td { border-bottom: 0; } .admin-table tbody tr:hover td { background: #fafbfc; } .games-table td:first-child { font-weight: 700; } .games-table td:nth-child(2) { color: var(--wf-muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9rem; } .games-table td:nth-child(3) { text-transform: capitalize; } .games-table td:last-child { white-space: nowrap; } .games-table td:last-child a { display: inline-block; margin: 2px 5px 2px 0; padding: 6px 9px; border: 1px solid var(--wf-border-strong); border-radius: 5px; background: var(--wf-surface); color: var(--wf-text); text-decoration: none; font-size: 0.9rem; font-weight: 600; } .games-table td:last-child a:hover { border-color: #9cabc0; background: var(--wf-accent-soft); color: var(--wf-accent-hover); } /* Make the primary builder action visually stronger. */ .games-table td:last-child a:first-child { border-color: #a9bddc; background: var(--wf-accent-soft); color: var(--wf-accent); } /* ------------------------------------------------------------------ RESPONSIVE ------------------------------------------------------------------ */ @media (max-width: 760px) { .wayfarer-admin-page { padding: 16px; } .games-table { display: block; overflow-x: auto; white-space: nowrap; } } @media (max-width: 520px) { .wayfarer-admin-page > h1 { font-size: 1.65rem; } .admin-table th, .admin-table td { padding: 10px; } } /* ========================================================================== GAME SETTINGS PAGE — v13.1 ========================================================================== */ .wayfarer-game-edit-page { padding-bottom: 60px; } .wayfarer-game-edit-page > h1, .wayfarer-game-edit-page > .admin-back-link, .wayfarer-game-edit-page > .admin-error-message, .wayfarer-game-edit-page > .admin-form-card { max-width: 980px; } .admin-back-link { margin-top: 0; margin-bottom: 14px; } .admin-back-link a { text-decoration: none; font-weight: 600; } .admin-error-message { padding: 11px 14px; border: 1px solid #e8b8be; border-radius: 6px; background: #fff4f5; color: #9a1b2a; font-weight: 600; } .admin-form-card { padding: 22px 26px 0; border: 1px solid var(--wf-border); border-radius: var(--wf-radius); background: var(--wf-surface); box-shadow: var(--wf-shadow); } .admin-form-card > p:first-of-type { margin-top: 0; } .admin-form-card h2, .admin-form-card h3 { color: var(--wf-text); } .admin-form-card h2 { margin-top: 26px; margin-bottom: 6px; font-size: 1.2rem; } .admin-form-card label { color: #303640; font-weight: 600; } .admin-form-card small { color: var(--wf-muted); font-weight: 400; } .admin-form-card input[type="text"], .admin-form-card input[type="number"], .admin-form-card input[type="password"], .admin-form-card input[type="file"], .admin-form-card select, .admin-form-card textarea { box-sizing: border-box; max-width: 100%; margin-top: 5px; padding: 8px 9px; border: 1px solid var(--wf-border-strong); border-radius: 5px; background: #fff; color: var(--wf-text); font: inherit; } .admin-form-card input[type="text"], .admin-form-card textarea { width: min(100%, 720px); } .admin-form-card textarea { min-height: 110px; resize: vertical; } .admin-form-card input:focus, .admin-form-card select:focus, .admin-form-card textarea:focus { outline: 2px solid rgba(47, 94, 168, 0.18); border-color: #7f9dc8; } .admin-form-card > hr { margin: 26px -26px 22px; border: 0; border-top: 1px solid var(--wf-border); } /* * This deliberately styles any fieldset in Game Settings rather than * depending on the old traits-container/add-trait implementation. */ .admin-form-card fieldset { margin: 16px 0; padding: 16px 18px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface-soft); } .admin-form-card fieldset legend { padding: 0 6px; color: #404650; font-weight: 700; } .admin-form-card fieldset > p { margin-top: 10px; margin-bottom: 10px; } /* * Existing trait row hooks continue to get slightly tighter handling when * present, but they are not required. */ .admin-form-card .trait-row { margin-bottom: 14px !important; } .admin-form-card .trait-number-fields { display: flex; flex-wrap: wrap; gap: 16px; } .admin-form-card .trait-number-fields > p { margin-top: 0; margin-bottom: 0; } /* ------------------------------------------------------------------ BUTTONS ------------------------------------------------------------------ */ .admin-form-card button, .admin-primary-button, .admin-secondary-button, .admin-danger-button { padding: 8px 13px; border: 1px solid var(--wf-border-strong); border-radius: 6px; background: var(--wf-surface); color: var(--wf-text); font: inherit; font-weight: 600; cursor: pointer; } .admin-form-card button:hover { background: var(--wf-accent-soft); border-color: #9cabc0; } .admin-primary-button { border-color: #214b86 !important; background: var(--wf-accent) !important; color: #fff !important; } .admin-primary-button:hover { background: var(--wf-accent-hover) !important; } .admin-secondary-button { background: var(--wf-surface) !important; } .admin-danger-button, .admin-form-card .remove-trait { border-color: #d7a4ab !important; background: #fff !important; color: #a61b2b !important; } .admin-danger-button:hover, .admin-form-card .remove-trait:hover { background: #fff4f5 !important; } /* * Treat the last form paragraph as the Save footer. In the current editor, * the Save Game control lives there. */ .admin-form-card > p:last-child { display: flex; justify-content: flex-end; margin: 22px -26px 0; padding: 14px 26px; border-top: 1px solid var(--wf-border); border-radius: 0 0 var(--wf-radius) var(--wf-radius); background: var(--wf-surface-soft); } /* ------------------------------------------------------------------ RESPONSIVE ------------------------------------------------------------------ */ @media (max-width: 720px) { .admin-form-card { padding: 18px 18px 0; } .admin-form-card > hr { margin-left: -18px; margin-right: -18px; } .admin-form-card > p:last-child { margin-left: -18px; margin-right: -18px; padding-left: 18px; padding-right: 18px; } .admin-form-card .trait-number-fields { display: block; } } /* ========================================================================== GAME SETTINGS TABS — v22 Mirrors the authored-object tab language used by builder.php. ========================================================================== */ .admin-game-tabs-form { padding-top: 0; } .admin-game-tabs { display: flex; gap: 4px; margin: 0 -26px 20px; padding: 0 26px; border-bottom: 1px solid var(--wf-border); background: var(--wf-surface); border-radius: var(--wf-radius) var(--wf-radius) 0 0; } .admin-game-tab { position: relative; bottom: -1px; margin: 0; padding: 12px 14px !important; border: 1px solid transparent !important; border-bottom: 1px solid var(--wf-border) !important; border-radius: 0 !important; background: transparent !important; color: var(--wf-muted) !important; font: inherit; font-weight: 600 !important; cursor: pointer; } .admin-game-tab:hover { color: var(--wf-text) !important; background: var(--wf-surface-soft) !important; } .admin-game-tab.is-active { color: var(--wf-accent) !important; background: var(--wf-surface) !important; border-color: var(--wf-border) var(--wf-border) var(--wf-surface) !important; border-radius: 6px 6px 0 0 !important; } .admin-game-tab-panel[hidden] { display: none !important; } .admin-game-tab-panel > h2:first-child { margin-top: 0; } .admin-game-tab-panel > p:first-child { margin-top: 0; } .admin-game-panel-intro { color: var(--wf-muted); } .admin-game-placeholder-card { margin: 16px 0 4px; padding: 16px 18px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface-soft); } .admin-game-placeholder-card > p:last-child { margin-bottom: 0; } .admin-game-save-bar { display: flex; justify-content: flex-end; margin: 22px -26px 0; padding: 14px 26px; border-top: 1px solid var(--wf-border); border-radius: 0 0 var(--wf-radius) var(--wf-radius); background: var(--wf-surface-soft); } /* * The old rule treated the form's last direct

as the save footer. * The tabbed editor now has an explicit save bar. */ .admin-game-tabs-form > p:last-child { display: block; margin: initial; padding: initial; border: 0; background: transparent; } @media (max-width: 720px) { .admin-game-tabs { overflow-x: auto; margin-left: -18px; margin-right: -18px; padding-left: 18px; padding-right: 18px; } .admin-game-tab { white-space: nowrap; } .admin-game-save-bar { margin-left: -18px; margin-right: -18px; padding-left: 18px; padding-right: 18px; } } /* ========================================================================== WAYFARER_GAME_SETTINGS_BUILDER_MATCH_V23 Make Game Settings use the same visual language as the object editor in builder.php: wide editor card, compact tabs, 720px fields, and persistent save footer. ========================================================================== */ .wayfarer-game-edit-page > h1, .wayfarer-game-edit-page > .admin-back-link, .wayfarer-game-edit-page > .admin-error-message, .wayfarer-game-edit-page > .admin-form-card { max-width: 1400px; } /* Match the Builder editor card. */ .wayfarer-game-edit-page .admin-game-tabs-form { padding: 20px 24px 0; border: 1px solid var(--wf-border); border-radius: var(--wf-radius); background: var(--wf-surface); box-shadow: var(--wf-shadow); } /* Builder tabs sit inside the editor padding rather than spanning the card. */ .wayfarer-game-edit-page .admin-game-tabs { display: flex; gap: 4px; margin: 0 0 20px; padding: 0; border-bottom: 1px solid var(--wf-border); background: transparent; border-radius: 0; } .wayfarer-game-edit-page .admin-game-tab { position: relative; bottom: -1px; margin: 0; padding: 9px 14px !important; border: 1px solid transparent !important; border-bottom: 1px solid var(--wf-border) !important; border-radius: 0 !important; background: transparent !important; color: var(--wf-muted) !important; font: inherit; font-weight: 600 !important; cursor: pointer; } .wayfarer-game-edit-page .admin-game-tab:hover { color: var(--wf-text) !important; background: var(--wf-surface-soft) !important; } .wayfarer-game-edit-page .admin-game-tab.is-active { color: var(--wf-accent) !important; background: var(--wf-surface) !important; border-color: var(--wf-border) var(--wf-border) var(--wf-surface) !important; border-radius: 6px 6px 0 0 !important; } /* Match Builder field sizing. */ .wayfarer-game-edit-page .admin-game-tab-panel label { font-weight: 600; } .wayfarer-game-edit-page .admin-game-tab-panel small { color: var(--wf-muted); font-weight: 400; } .wayfarer-game-edit-page .admin-game-tab-panel input[type="text"], .wayfarer-game-edit-page .admin-game-tab-panel input[type="number"], .wayfarer-game-edit-page .admin-game-tab-panel input[type="file"], .wayfarer-game-edit-page .admin-game-tab-panel select, .wayfarer-game-edit-page .admin-game-tab-panel textarea { box-sizing: border-box; max-width: 100%; margin-top: 4px; padding: 7px 8px; border: 1px solid var(--wf-border-strong); border-radius: 5px; background: var(--wf-surface); color: var(--wf-text); font: inherit; } .wayfarer-game-edit-page .admin-game-tab-panel input[type="text"], .wayfarer-game-edit-page .admin-game-tab-panel textarea { width: min(100%, 720px); } .wayfarer-game-edit-page .admin-game-tab-panel textarea { resize: vertical; } /* Do not let old Game Settings paragraph/footer rules distort tab contents. */ .wayfarer-game-edit-page .admin-game-tab-panel > p { display: block; justify-content: initial; margin-left: 0; margin-right: 0; padding-left: 0; padding-right: 0; border: 0; border-radius: 0; background: transparent; } /* Match builder.php's save bar exactly. */ .wayfarer-game-edit-page .admin-game-save-bar { display: flex; justify-content: flex-end; margin: 22px -24px 0; padding: 14px 24px; border-top: 1px solid var(--wf-border); border-radius: 0 0 var(--wf-radius) var(--wf-radius); background: var(--wf-surface-soft); } .wayfarer-game-edit-page .admin-game-save-bar .admin-primary-button { padding: 9px 16px; } /* Trait fieldsets should also resemble Builder fieldsets. */ .wayfarer-game-edit-page .admin-game-tab-panel fieldset { margin: 0 0 16px; padding: 14px 16px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface); } .wayfarer-game-edit-page .admin-game-tab-panel fieldset legend { padding: 0 6px; } /* Responsive behavior mirrors Builder. */ @media (max-width: 900px) { .wayfarer-game-edit-page .admin-game-tabs-form { padding: 18px 18px 0; } .wayfarer-game-edit-page .admin-game-save-bar { margin-left: -18px; margin-right: -18px; padding-left: 18px; padding-right: 18px; } } @media (max-width: 560px) { .wayfarer-game-edit-page .admin-game-tabs { overflow-x: auto; margin-left: 0; margin-right: 0; padding-left: 0; padding-right: 0; } .wayfarer-game-edit-page .admin-game-tab { white-space: nowrap; } } ================================================================================ FILE: assets/css/builder.css ================================================================================ /* |-------------------------------------------------------------------------- | Wayfarer Builder |-------------------------------------------------------------------------- | Master/detail authored-object workspace. | This stylesheet is intentionally presentation-only. */ :root { --wf-border: #d8dbe2; --wf-border-strong: #b8bdc7; --wf-surface: #ffffff; --wf-surface-soft: #f6f7f9; --wf-text: #20242a; --wf-muted: #667085; --wf-accent: #2f5ea8; --wf-accent-soft: #edf3fc; --wf-danger: #a61b2b; --wf-danger-soft: #fff4f5; --wf-radius: 8px; --wf-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); } .wayfarer-builder-page { margin: 0; padding: 24px; background: #f3f4f6; color: var(--wf-text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.45; } .wayfarer-builder-page > h1, .wayfarer-builder-page > p, .wayfarer-builder-page > nav, .wayfarer-builder-page > .builder-workspace, .wayfarer-builder-page > .builder-back-link { max-width: 1400px; margin-left: auto; margin-right: auto; } .wayfarer-builder-page > h1 { margin-bottom: 2px; } .wayfarer-builder-page > h1 + p { margin-top: 0; color: var(--wf-muted); } .wayfarer-builder-page > nav { margin-top: 18px; margin-bottom: 18px; padding: 10px 14px; background: var(--wf-surface); border: 1px solid var(--wf-border); border-radius: var(--wf-radius); box-shadow: var(--wf-shadow); } .wayfarer-builder-page > nav a, .wayfarer-builder-page > nav strong { display: inline-block; margin-right: 18px; } .wayfarer-builder-page > hr { display: none; } /* ------------------------------------------------------------------ MASTER / DETAIL ------------------------------------------------------------------ */ .builder-workspace { display: grid; grid-template-columns: minmax(260px, 340px) minmax(0, 1fr); gap: 18px; align-items: start; } .builder-collection-pane, .builder-editor-pane { background: var(--wf-surface); border: 1px solid var(--wf-border); border-radius: var(--wf-radius); box-shadow: var(--wf-shadow); } .builder-collection-pane { position: sticky; top: 18px; max-height: calc(100vh - 36px); overflow: hidden; } .builder-collection-toolbar { padding: 14px; border-bottom: 1px solid var(--wf-border); background: var(--wf-surface-soft); } .builder-new-object { display: inline-block; padding: 8px 11px; border: 1px solid var(--wf-border-strong); border-radius: 6px; background: var(--wf-surface); text-decoration: none; font-weight: 600; } .builder-collection-scroll { overflow: auto; max-height: calc(100vh - 92px); padding: 14px; } .builder-collection-scroll > h2 { margin-top: 0; margin-bottom: 12px; font-size: 1.15rem; } .builder-collection-scroll ul { margin: 0; padding: 0; list-style: none; } .builder-collection-scroll li + li { margin-top: 4px; } .builder-collection-scroll li a { display: block; padding: 8px 9px; border-radius: 5px; text-decoration: none; } .builder-collection-scroll li a:hover { background: var(--wf-accent-soft); } .builder-collection-scroll table { width: 100%; border-collapse: collapse; font-size: 0.88rem; } .builder-collection-scroll th, .builder-collection-scroll td { padding: 7px 6px; border: 1px solid var(--wf-border); text-align: left; vertical-align: top; } .builder-collection-scroll th button { border: 0; padding: 0; background: transparent; font: inherit; font-weight: 600; cursor: pointer; } .builder-editor-pane { min-width: 0; padding: 20px 24px 24px; } .builder-editor-pane > h2 { margin-top: 0; margin-bottom: 14px; } /* WAYFARER_OBJECT_CONTEXT_HEADING_V34 */ .builder-object-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 8px; } .builder-object-context { color: var(--wf-muted); font-weight: 600; } .builder-object-context[hidden] { display: none; } .builder-object-context-separator { margin-right: 8px; color: var(--wf-border-strong); } /* ------------------------------------------------------------------ TABBED OBJECT EDITOR ------------------------------------------------------------------ */ .builder-object-form { margin: 0; } .builder-editor-tabs { display: flex; gap: 4px; margin: 0 0 20px; padding: 0; border-bottom: 1px solid var(--wf-border); } .builder-editor-tab { position: relative; bottom: -1px; padding: 9px 14px; border: 1px solid transparent; border-bottom: 1px solid var(--wf-border); background: transparent; color: var(--wf-muted); font: inherit; font-weight: 600; cursor: pointer; } .builder-editor-tab:hover { color: var(--wf-text); background: var(--wf-surface-soft); } .builder-editor-tab.is-active { color: var(--wf-accent); background: var(--wf-surface); border-color: var(--wf-border) var(--wf-border) var(--wf-surface); border-radius: 6px 6px 0 0; } .builder-tab-panel[hidden] { display: none !important; } .builder-tab-panel > p:first-child { margin-top: 0; } /* ------------------------------------------------------------------ EXISTING FIELD CONTROLS ------------------------------------------------------------------ */ .builder-editor-pane label { font-weight: 600; } .builder-editor-pane small { color: var(--wf-muted); font-weight: 400; } .builder-editor-pane input[type="text"], .builder-editor-pane input[type="number"], .builder-editor-pane input[type="file"], .builder-editor-pane select, .builder-editor-pane textarea { box-sizing: border-box; max-width: 100%; margin-top: 4px; padding: 7px 8px; border: 1px solid var(--wf-border-strong); border-radius: 5px; background: var(--wf-surface); color: var(--wf-text); font: inherit; } .builder-editor-pane input[type="text"], .builder-editor-pane textarea { width: min(100%, 720px); } .builder-editor-pane textarea { resize: vertical; } .builder-editor-pane fieldset { margin: 0 0 16px; padding: 14px 16px; border: 1px solid var(--wf-border); border-radius: 7px; } .builder-editor-pane legend { padding: 0 6px; } .builder-editor-pane table { max-width: 100%; border-collapse: collapse; } .builder-editor-pane th, .builder-editor-pane td { padding: 7px; } /* System settings stay visually inside Basics. */ .builder-editor-pane [data-system-trait-settings] { margin: 16px 0; padding: 14px 16px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface-soft); } .builder-editor-pane [data-system-trait-settings] h3 { margin-top: 0; } /* ------------------------------------------------------------------ SAVE / DELETE ------------------------------------------------------------------ */ .builder-save-bar { display: flex; justify-content: flex-end; margin: 22px -24px -24px; padding: 14px 24px; border-top: 1px solid var(--wf-border); background: var(--wf-surface-soft); border-radius: 0 0 var(--wf-radius) var(--wf-radius); } .builder-save-bar button { padding: 9px 16px; border: 1px solid #214b86; border-radius: 6px; background: var(--wf-accent); color: #fff; font: inherit; font-weight: 600; cursor: pointer; } .builder-editor-pane [data-builder-delete-form] { margin-top: 28px; padding: 16px; border: 1px solid #e8b8be; border-radius: 7px; background: var(--wf-danger-soft); } .builder-editor-pane [data-builder-delete-form] h3 { margin-top: 0; color: var(--wf-danger); } .builder-editor-pane [data-builder-delete-form] button { padding: 8px 12px; border: 1px solid var(--wf-danger); border-radius: 5px; background: #fff; color: var(--wf-danger) !important; font: inherit; font-weight: 600; cursor: pointer; } .builder-back-link { margin-top: 18px; } /* ------------------------------------------------------------------ RESPONSIVE ------------------------------------------------------------------ */ @media (max-width: 900px) { .wayfarer-builder-page { padding: 14px; } .builder-workspace { grid-template-columns: 1fr; } .builder-collection-pane { position: static; max-height: none; } .builder-collection-scroll { max-height: 300px; } .builder-editor-pane { padding: 18px; } .builder-save-bar { margin-left: -18px; margin-right: -18px; margin-bottom: -18px; padding-left: 18px; padding-right: 18px; } } @media (max-width: 560px) { .builder-editor-tabs { overflow-x: auto; } .builder-editor-tab { white-space: nowrap; } } /* |-------------------------------------------------------------------------- | REGION TICK EFFECTS v18 |-------------------------------------------------------------------------- */ .builder-tick-effect-row { display: grid; grid-template-columns: minmax(220px, 2fr) minmax(130px, 1fr) minmax(110px, 1fr) auto; gap: 12px; align-items: end; margin: 0 0 12px; padding: 14px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface-soft); } .builder-tick-effect-row > p { margin: 0; } .builder-tick-effect-row select, .builder-tick-effect-row input { width: 100%; box-sizing: border-box; } .builder-tick-effect-remove { align-self: end; } @media (max-width: 760px) { .builder-tick-effect-row { grid-template-columns: 1fr; } } /* |-------------------------------------------------------------------------- | TICK EFFECT RESULT BRANCHES v28 |-------------------------------------------------------------------------- | WAYFARER_TICK_EFFECT_BRANCHES_V28 */ .builder-tick-effect-row { display: block; margin: 0 0 16px; padding: 16px; } .builder-tick-effect-header { display: grid; grid-template-columns: minmax(260px, 1fr) auto; gap: 16px; align-items: end; margin-bottom: 16px; } .builder-tick-effect-target select { display: block; width: 100%; margin-top: 6px; } .builder-tick-result-list { display: grid; gap: 12px; } .builder-tick-result-branch { padding: 14px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface); } .builder-tick-result-branch.is-otherwise { background: var(--wf-surface-soft); } .builder-tick-result-heading { display: flex; justify-content: space-between; gap: 12px; align-items: center; margin-bottom: 12px; } .builder-tick-result-order { display: flex; gap: 6px; align-items: center; } .builder-tick-result-condition { margin-bottom: 10px; } .builder-tick-result-then { margin: 10px 0 8px; } .builder-tick-result-fields { display: grid; grid-template-columns: minmax(150px, 0.8fr) minmax(120px, 0.7fr) minmax(120px, 0.7fr) minmax(180px, 1fr); gap: 12px; align-items: end; } .builder-tick-result-fields select, .builder-tick-result-fields input { display: block; width: 100%; margin-top: 6px; } .builder-tick-result-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; } .builder-condition-group { padding: 10px; border: 1px solid var(--wf-border); border-radius: 6px; background: var(--wf-surface-soft); } .builder-condition-group-header, .builder-condition-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .builder-condition-children { display: grid; gap: 8px; margin: 10px 0; } .builder-condition-row { display: grid; grid-template-columns: minmax(180px, 1.4fr) minmax(180px, 1fr) minmax(120px, 1fr) auto; gap: 8px; align-items: end; } .builder-condition-row select, .builder-condition-row input, .builder-condition-group-header select { width: 100%; box-sizing: border-box; } @media (max-width: 760px) { .builder-tick-effect-header, .builder-tick-result-fields, .builder-condition-row { grid-template-columns: 1fr; } .builder-tick-result-heading { align-items: flex-start; flex-direction: column; } } /* ------------------------------------------------------------------ STARTING CHOICES WAYFARER_STARTING_CHOICES_V36 ------------------------------------------------------------------ */ .builder-starting-choices-editor { margin-top: 24px; } .builder-starting-choices-heading, .builder-starting-choice-header, .builder-starting-option-header, .builder-starting-effects-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } .builder-starting-choices-heading h3 { margin: 0 0 4px; } .builder-starting-choices-heading p { margin: 0; } .builder-starting-choice { margin-top: 16px; padding: 16px; border: 1px solid var(--wf-border); border-radius: var(--wf-radius); background: var(--wf-surface-soft); } .builder-starting-choice-fields { display: grid; grid-template-columns: minmax(0, 1fr) 140px; gap: 12px; margin-top: 14px; } .builder-starting-choice-fields label, .builder-starting-option-fields label, .builder-starting-effect label { display: grid; gap: 5px; font-weight: 600; } .builder-starting-choice-fields input, .builder-starting-choice-fields select, .builder-starting-option-fields input, .builder-starting-option-fields textarea, .builder-starting-effect input, .builder-starting-effect select { width: 100%; font: inherit; } .builder-starting-options { display: grid; gap: 12px; margin: 16px 0 12px; } .builder-starting-option { padding: 14px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface); } .builder-starting-option-fields { display: grid; grid-template-columns: minmax(180px, 0.7fr) minmax(260px, 1.3fr); gap: 12px; margin-top: 12px; } .builder-starting-effects { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--wf-border); } .builder-starting-effects-heading { margin-bottom: 8px; } .builder-starting-effects-heading small { color: var(--wf-muted); } .builder-starting-effect { display: grid; grid-template-columns: minmax(180px, 1.4fr) minmax(110px, 0.65fr) minmax(110px, 0.65fr) minmax(110px, 0.65fr) minmax(180px, 1fr) auto; gap: 10px; align-items: end; margin-bottom: 8px; } @media (max-width: 800px) { .builder-starting-choice-fields, .builder-starting-option-fields, .builder-starting-effect { grid-template-columns: 1fr; } } /* ------------------------------------------------------------------ ONE-TIME EFFECTS WAYFARER_ONE_TIME_EFFECTS_EDITOR_V42 ------------------------------------------------------------------ */ .builder-effects-panel > hr { margin: 28px 0; border: 0; border-top: 1px solid var(--wf-border, #d8dbe2); } .builder-effects-list { display: grid; gap: 10px; margin: 14px 0; } .builder-effect-row { display: grid; grid-template-columns: minmax(220px, 1.5fr) minmax(130px, 0.7fr) minmax(110px, 0.6fr) auto; gap: 10px; align-items: end; padding: 12px; border: 1px solid var(--wf-border, #d8dbe2); border-radius: 8px; background: #ffffff; } .builder-effect-row label { display: grid; gap: 5px; } .builder-effect-row select, .builder-effect-row input { width: 100%; } .builder-effect-remove { align-self: end; } @media (max-width: 850px) { .builder-effect-row { grid-template-columns: 1fr; } .builder-effect-remove { justify-self: start; } } /* ------------------------------------------------------------------ GENERALIZED EFFECTS WAYFARER_GENERALIZED_EFFECT_EDITOR_V43 ------------------------------------------------------------------ */ /* * v42 displayed an Effect as one horizontal operation row. v43 Effects own * ordered result branches, so use the same card language as Tick Effects. */ .builder-effect-row { display: block; grid-template-columns: none; padding: 14px; } .builder-effect-row .builder-tick-effect-header { margin-bottom: 12px; } .builder-effect-value-fields { display: grid; grid-template-columns: minmax(120px, 0.75fr) minmax(120px, 0.75fr) minmax(120px, 0.75fr); gap: 10px; align-items: end; } .builder-effect-random-range { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .builder-effect-random-range > label { display: grid; gap: 5px; } [data-effect-fixed-wrap][hidden], [data-effect-random-wrap][hidden], [data-effect-value-mode-wrap][hidden] { display: none !important; } /* WAYFARER_DATATYPE_TARGET_UI_V58 */ [data-condition-value-input][hidden], [data-condition-value-select][hidden], [data-starting-value-mode-wrap][hidden], [data-starting-fixed-wrap][hidden], [data-starting-random-wrap][hidden], [data-starting-fixed-input][hidden], [data-starting-fixed-choice][hidden], [data-tick-value-mode-wrap][hidden], [data-tick-branch-value-wrap][hidden], [data-tick-random-wrap][hidden], [data-tick-fixed-choice][hidden], [data-effect-fixed-choice][hidden] { display: none !important; } @media (max-width: 850px) { .builder-effect-value-fields { grid-template-columns: 1fr; } .builder-effect-random-range { grid-template-columns: 1fr; } } /* ------------------------------------------------------------------ STORYLET BRANCHES WAYFARER_STORYLET_BUILDER_V56 ------------------------------------------------------------------ */ .builder-storylet-branches { max-width: 100%; overflow-x: auto; } .builder-storylet-branches table { width: 100%; border-collapse: collapse; } .builder-storylet-branches th, .builder-storylet-branches td { padding: 9px 10px; border: 1px solid var(--wf-border); text-align: left; vertical-align: top; } .builder-storylet-branches th { background: var(--wf-surface-soft); } /* ------------------------------------------------------------------ STATUS LIFECYCLE WAYFARER_STATUS_LIFECYCLE_UI_V88 ------------------------------------------------------------------ */ .wayfarer-status-lifecycle-heading, .wayfarer-status-stage-heading, .wayfarer-status-outcomes-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } .wayfarer-status-lifecycle-heading h4, .wayfarer-status-outcomes-heading strong { margin: 0; } .wayfarer-status-stage { margin-top: 16px; padding: 16px; border: 1px solid var(--wf-border); border-radius: 8px; background: var(--wf-surface-soft); } .wayfarer-status-stage-actions { display: flex; flex-wrap: wrap; gap: 6px; } .wayfarer-status-stage-fields, .wayfarer-status-outcome-fields { display: grid; gap: 12px; margin-top: 14px; } .wayfarer-status-stage-fields { grid-template-columns: minmax(180px, 1fr) repeat(4, minmax(130px, 0.7fr)); } .wayfarer-status-outcome-fields { grid-template-columns: minmax(180px, 1fr) minmax(90px, 0.45fr) minmax(210px, 1fr) minmax(150px, 0.7fr) minmax(220px, 1fr); } .wayfarer-status-stage-fields label, .wayfarer-status-outcome-fields label { display: grid; gap: 5px; } .wayfarer-status-stage-fields input, .wayfarer-status-stage-fields select, .wayfarer-status-outcome-fields input, .wayfarer-status-outcome-fields select { width: 100%; } .wayfarer-status-outcomes-heading { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--wf-border); } .wayfarer-status-outcomes-heading small { display: block; margin-top: 2px; } .wayfarer-status-outcome { margin-top: 10px; padding: 12px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface); } .wayfarer-status-outcome > button { margin-top: 10px; } .wayfarer-status-lifecycle-note { margin-top: 18px; } @media (max-width: 1050px) { .wayfarer-status-stage-fields, .wayfarer-status-outcome-fields { grid-template-columns: 1fr 1fr; } } @media (max-width: 700px) { .wayfarer-status-lifecycle-heading, .wayfarer-status-stage-heading, .wayfarer-status-outcomes-heading { flex-direction: column; } .wayfarer-status-stage-fields, .wayfarer-status-outcome-fields { grid-template-columns: 1fr; } } ================================================================================ FILE: assets/css/hierarchical-targets.css ================================================================================ /* * Wayfarer Hierarchical Target Picker * WAYFARER_HIERARCHICAL_TARGETS_V80 * WAYFARER_HIERARCHICAL_LAYOUT_V81 */ .wayfarer-hierarchy-picker { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; width: 100%; min-width: 0; margin-top: 6px; } .wayfarer-hierarchy-level { min-width: 0; } .wayfarer-hierarchy-label { display: block; margin-bottom: 4px; font-weight: 600; } .wayfarer-hierarchy-search, .wayfarer-hierarchy-select { box-sizing: border-box; width: 100%; min-width: 0; } .wayfarer-hierarchy-search { margin-bottom: 5px; } .wayfarer-hierarchy-level[hidden] { display: none !important; } /* * The canonical target select stays in the form so all existing save/runtime * behavior remains unchanged. V81 moves the generated hierarchy outside its * original label, then hides that now-redundant source label. */ .wayfarer-hierarchy-canonical, .wayfarer-hierarchy-source-label { display: none !important; } /* * CONDITIONS * * Row 1: Type -> Target -> Property hierarchy. * Row 2: Comparison | Value | Remove. * * A hidden Value control (for Location anchors, etc.) naturally collapses out * of the second row without changing the underlying Condition shape. */ .builder-condition-row.has-wayfarer-hierarchy { display: grid !important; grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto !important; gap: 12px !important; align-items: end !important; } .builder-condition-row.has-wayfarer-hierarchy > .wayfarer-hierarchy-picker { grid-column: 1 / -1; margin-top: 0; } .builder-condition-row.has-wayfarer-hierarchy > label:not(.wayfarer-hierarchy-source-label) { min-width: 0; } .builder-condition-row.has-wayfarer-hierarchy > label:not(.wayfarer-hierarchy-source-label) > select, .builder-condition-row.has-wayfarer-hierarchy > label:not(.wayfarer-hierarchy-source-label) > input { box-sizing: border-box; max-width: 100%; } .builder-condition-row.has-wayfarer-hierarchy > [data-remove-condition-node] { align-self: end; } /* * ONE-TIME EFFECTS / TICK EFFECTS * * The hierarchy gets the flexible column; Remove remains a compact action * column. This prevents the three hierarchy levels from fighting the old * single-select header width. */ .builder-tick-effect-header.has-wayfarer-hierarchy { display: grid !important; grid-template-columns: minmax(0, 1fr) auto !important; gap: 12px !important; align-items: end !important; } .builder-tick-effect-header.has-wayfarer-hierarchy > .wayfarer-hierarchy-picker { grid-column: 1; margin-top: 0; } .builder-tick-effect-header.has-wayfarer-hierarchy > [data-remove-effect], .builder-tick-effect-header.has-wayfarer-hierarchy > [data-remove-tick-effect] { grid-column: 2; align-self: end; } @media (max-width: 900px) { .wayfarer-hierarchy-picker { grid-template-columns: 1fr; } .builder-condition-row.has-wayfarer-hierarchy, .builder-tick-effect-header.has-wayfarer-hierarchy { grid-template-columns: 1fr !important; } .builder-condition-row.has-wayfarer-hierarchy > .wayfarer-hierarchy-picker, .builder-tick-effect-header.has-wayfarer-hierarchy > .wayfarer-hierarchy-picker, .builder-tick-effect-header.has-wayfarer-hierarchy > [data-remove-effect], .builder-tick-effect-header.has-wayfarer-hierarchy > [data-remove-tick-effect] { grid-column: 1; } } ================================================================================ FILE: assets/css/status-subject-effects.css ================================================================================ /* WAYFARER_STATUS_SUBJECT_EFFECTS_UI_V89 */ /* WAYFARER_STATUS_EFFECT_UI_CLEANUP_V100 */ /* * V100: JavaScript uses the HTML hidden attribute for mutually-exclusive * Status controls. The grid rules below otherwise override the browser's * default [hidden] display rule, so make hidden controls collapse explicitly. */ .wayfarer-status-lifecycle [hidden], .wayfarer-status-effect [hidden] { display: none !important; } .wayfarer-status-stage-effects, .wayfarer-status-outcome-effects { margin-top: 16px; } .wayfarer-status-effects-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--wf-border); } .wayfarer-status-effects-heading small { display: block; margin-top: 2px; } .wayfarer-status-effect { display: grid; grid-template-columns: minmax(170px, 1fr) minmax(170px, 1fr) minmax(120px, 0.7fr) minmax(120px, 0.7fr) minmax(110px, 0.65fr) minmax(110px, 0.65fr) auto; gap: 10px; align-items: end; margin-top: 10px; padding: 11px; border: 1px solid var(--wf-border); border-radius: 7px; background: #fff; } .wayfarer-status-effect label { display: grid; gap: 5px; min-width: 0; } .wayfarer-status-effect select, .wayfarer-status-effect input { width: 100%; min-width: 0; } .wayfarer-status-effect-inline-number { display: grid; grid-template-columns: minmax(70px, 1fr) auto; gap: 6px; align-items: center; } .wayfarer-status-effect > button { align-self: end; } @media (max-width: 1150px) { .wayfarer-status-effect { grid-template-columns: 1fr 1fr 1fr; } } @media (max-width: 700px) { .wayfarer-status-effects-heading { flex-direction: column; } .wayfarer-status-effect { grid-template-columns: 1fr; } } ================================================================================ FILE: assets/css/storylet-tokens.css ================================================================================ /* WAYFARER_STORYLET_TOKEN_PICKER_V101 */ .wayfarer-storylet-token-picker { position: relative; display: inline-block; margin-top: 8px; } .wayfarer-storylet-token-menu { position: absolute; z-index: 30; top: calc(100% + 6px); left: 0; width: min(520px, calc(100vw - 48px)); max-height: 420px; overflow: auto; padding: 12px; border: 1px solid var(--wf-border-strong); border-radius: 7px; background: var(--wf-surface); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14); } .wayfarer-storylet-token-menu[hidden] { display: none !important; } .wayfarer-storylet-token-group + .wayfarer-storylet-token-group { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--wf-border); } .wayfarer-storylet-token-group > strong, .wayfarer-storylet-token-group > small { display: block; } .wayfarer-storylet-token-group > small { margin: 2px 0 8px; } .wayfarer-storylet-token-option { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; width: 100%; margin-top: 6px; padding: 8px 10px; border: 1px solid var(--wf-border); border-radius: 5px; background: var(--wf-surface-soft); color: var(--wf-text); font: inherit; text-align: left; cursor: pointer; } .wayfarer-storylet-token-option:hover, .wayfarer-storylet-token-option:focus { border-color: var(--wf-accent); background: var(--wf-accent-soft); } .wayfarer-storylet-token-option code { white-space: nowrap; } .wayfarer-storylet-token-note, .wayfarer-storylet-token-empty { color: var(--wf-muted); } @media (max-width: 640px) { .wayfarer-storylet-token-menu { width: min(420px, calc(100vw - 36px)); } .wayfarer-storylet-token-option { grid-template-columns: 1fr; gap: 3px; } } ================================================================================ FILE: assets/js/builder.js ================================================================================ const wayfarerConditionData = window.wayfarerConditionData || {}; function updateConditionalConfigFields() { document .querySelectorAll('[data-config-field]') .forEach(function (container) { const relationshipName = container.dataset.conditionRelationship; const requiredValue = container.dataset.conditionEquals; if (!relationshipName) { container.style.display = ''; return; } const relationshipSelect = document.querySelector( '[data-relationship="' + relationshipName + '"]' ); if (!relationshipSelect) { container.style.display = 'none'; return; } const selectedId = relationshipSelect.value; const actualValue = wayfarerConditionData[relationshipName]?.[selectedId]; container.style.display = actualValue === requiredValue ? '' : 'none'; }); } document .querySelectorAll('[data-relationship]') .forEach(function (select) { select.addEventListener( 'change', updateConditionalConfigFields ); }); // ================================================================ // SYSTEM TRAIT GROUPS // ================================================================ function updateSystemTraitGroups(container) { const selectorField = container.dataset.selectorField; if (!selectorField) { return; } const selector = document.querySelector( '[name="' + selectorField + '"]' ); if (!selector) { return; } const selectedGroup = selector.value; container .querySelectorAll('[data-system-trait-group]') .forEach(function (group) { const active = group.dataset.systemTraitGroup === selectedGroup; group.style.display = active ? '' : 'none'; group .querySelectorAll('input, select, textarea') .forEach(function (input) { input.disabled = !active; }); }); } document .querySelectorAll('[data-system-trait-settings]') .forEach(function (container) { const selectorField = container.dataset.selectorField; const selector = selectorField ? document.querySelector( '[name="' + selectorField + '"]' ) : null; if (selector) { selector.addEventListener( 'change', function () { updateSystemTraitGroups(container); } ); } updateSystemTraitGroups(container); }); // ================================================================ // COLLECTION SORTING // ================================================================ document .querySelectorAll('[data-sort-column]') .forEach(function (button) { button.addEventListener('click', function () { const table = button.closest('[data-collection-table]'); if (!table) { return; } const tbody = table.querySelector('tbody'); const columnName = button.dataset.sortColumn; const sortType = button.dataset.sortType || 'text'; const currentDirection = button.dataset.sortDirection || 'none'; const nextDirection = currentDirection === 'asc' ? 'desc' : 'asc'; table .querySelectorAll('[data-sort-column]') .forEach(function (otherButton) { otherButton.dataset.sortDirection = 'none'; }); button.dataset.sortDirection = nextDirection; const rows = Array.from(tbody.querySelectorAll('tr')); rows.sort(function (a, b) { const aCell = a.querySelector( '[data-column="' + columnName + '"]' ); const bCell = b.querySelector( '[data-column="' + columnName + '"]' ); const aValue = aCell?.dataset.sortValue ?? ''; const bValue = bCell?.dataset.sortValue ?? ''; let comparison = 0; if (sortType === 'number') { const aNumber = aValue === '' ? Number.POSITIVE_INFINITY : Number(aValue); const bNumber = bValue === '' ? Number.POSITIVE_INFINITY : Number(bValue); comparison = aNumber - bNumber; } else { comparison = aValue.localeCompare( bValue, undefined, { numeric: true, sensitivity: 'base' } ); } return nextDirection === 'asc' ? comparison : -comparison; }); rows.forEach(function (row) { tbody.appendChild(row); }); }); }); updateConditionalConfigFields(); // ================================================================ // MEDIA LIBRARY CONTROLS // ================================================================ function updateBuilderMediaSlot(slotContainer) { const checkedAction = slotContainer.querySelector( 'input[type="radio"][name^="media_action"]:checked' ); const action = checkedAction ? checkedAction.value : 'none'; const existingSelect = slotContainer.querySelector( '[data-media-existing]' ); if (existingSelect) { existingSelect.disabled = action !== 'existing'; } slotContainer .querySelectorAll( '[data-media-upload-fields] input' ) .forEach(function (input) { input.disabled = action !== 'upload'; }); } document .querySelectorAll('[data-media-slot]') .forEach(function (slotContainer) { slotContainer .querySelectorAll( 'input[type="radio"][name^="media_action"]' ) .forEach(function (radio) { radio.addEventListener( 'change', function () { updateBuilderMediaSlot( slotContainer ); } ); }); updateBuilderMediaSlot( slotContainer ); }); // ================================================================ // AUTHORED OBJECT CONTEXT HEADING // WAYFARER_OBJECT_CONTEXT_HEADING_V34 // ================================================================ function initializeBuilderObjectContextHeading() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } const heading = editor.querySelector( '[data-builder-object-heading]' ); if (!heading) { return; } const fieldName = heading.dataset.builderObjectLabelField || 'name'; const form = editor.querySelector( 'form' ); if (!form) { return; } const field = Array .from( form.elements ) .find(function (element) { return ( element && element.name === fieldName ); }); if (!field) { return; } const context = heading.querySelector( '[data-builder-object-context]' ); const label = heading.querySelector( '[data-builder-object-context-label]' ); if (!context || !label) { return; } function refreshContextHeading() { const value = String( field.value || '' ).trim(); label.textContent = value; context.hidden = value === ''; } field.addEventListener( 'input', refreshContextHeading ); field.addEventListener( 'change', refreshContextHeading ); refreshContextHeading(); } initializeBuilderObjectContextHeading(); // ================================================================ // AUTHORED OBJECT EDITOR TABS // ================================================================ function initializeBuilderEditorTabs() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } /* * The editor component has one primary save form. The separate delete * form, when present, lives later in the editor pane and is deliberately * left outside the tab system. */ const form = Array .from(editor.children) .find(function (child) { return child.tagName === 'FORM'; }); if ( !form || form.dataset.builderTabbed === '1' ) { return; } form.dataset.builderTabbed = '1'; form.classList.add( 'builder-object-form' ); /* * WAYFARER_ACTIVE_TAB_V20 * * Persist the selected editor tab through POST/redirect/reload. */ const activeTabInput = document.createElement( 'input' ); activeTabInput.type = 'hidden'; activeTabInput.name = 'active_tab'; const requestedTab = new URLSearchParams( window.location.search ).get( 'tab' ) || 'basics'; activeTabInput.value = requestedTab; form.appendChild( activeTabInput ); const originalChildren = Array.from( form.children ); const panels = { basics: document.createElement('div'), media: document.createElement('div'), traits: document.createElement('div'), /* WAYFARER_CONDITIONS_PANEL_V45_1 */ conditions: document.createElement('div'), /* WAYFARER_LINEAR_TRAVEL_TAB_V72 */ travel: document.createElement('div'), /* WAYFARER_STATUS_LIFECYCLE_TAB_V88 */ progression: document.createElement('div'), tick_effects: document.createElement('div'), /* WAYFARER_STORYLET_BUILDER_V56 */ /* WAYFARER_STORYLET_BRANCH_PANEL_KEY_V57 */ branches: document.createElement('div') }; panels.basics.className = 'builder-tab-panel'; panels.media.className = 'builder-tab-panel'; panels.traits.className = 'builder-tab-panel'; panels.conditions.className = 'builder-tab-panel'; panels.travel.className = 'builder-tab-panel'; panels.progression.className = 'builder-tab-panel'; panels.branches.className = 'builder-tab-panel'; panels.basics.dataset.builderPanel = 'basics'; panels.media.dataset.builderPanel = 'media'; panels.traits.dataset.builderPanel = 'traits'; panels.conditions.dataset.builderPanel = 'conditions'; panels.travel.dataset.builderPanel = 'travel'; panels.progression.dataset.builderPanel = 'progression'; panels.branches.dataset.builderPanel = 'branches'; panels.tick_effects.className = 'builder-tab-panel'; panels.tick_effects.dataset.builderPanel = 'tick_effects'; let section = 'basics'; let saveContainer = null; let hasMedia = false; let hasTraits = false; let hasConditions = false; let hasTravel = false; let hasProgression = false; let hasTickEffects = false; let hasBranches = false; originalChildren.forEach( function (child) { /* * Keep hidden record identifiers directly in the form. */ if ( child.tagName === 'INPUT' && child.type === 'hidden' ) { return; } /* * Save becomes a stable footer beneath the tab panels. */ const submitButton = child.querySelector ? child.querySelector( 'button[type="submit"], input[type="submit"]' ) : null; if (submitButton) { saveContainer = child; return; } /* * Ignore the separator immediately before a tab heading. * Other HR elements are harmless but unnecessary inside the * cleaned-up editor. */ if (child.tagName === 'HR') { child.remove(); return; } if (child.tagName === 'H3') { const heading = child.textContent .trim() .toLowerCase(); if (heading === 'media') { section = 'media'; hasMedia = true; child.remove(); return; } if (heading === 'traits') { section = 'traits'; hasTraits = true; child.remove(); return; } } /* * WAYFARER_CONDITIONS_PANEL_V45_1 * * Conditions answer whether an authored object is eligible; * Effects answer what changes when it triggers. */ if ( child.hasAttribute && child.hasAttribute( 'data-builder-conditions-panel' ) ) { section = 'conditions'; hasConditions = true; } /* WAYFARER_LINEAR_TRAVEL_TAB_V72 */ if ( child.hasAttribute && child.hasAttribute( 'data-builder-travel-panel' ) ) { section = 'travel'; hasTravel = true; } /* WAYFARER_STATUS_LIFECYCLE_TAB_V88 */ if ( child.hasAttribute && child.hasAttribute( 'data-builder-status-lifecycle' ) ) { section = 'progression'; hasProgression = true; } /* * WAYFARER_ONE_TIME_EFFECTS_EDITOR_V42 * * Location Effects and Tick Effects share one Effects panel. */ if ( child.hasAttribute && ( child.hasAttribute( 'data-builder-effects-panel' ) || child.hasAttribute( 'data-builder-tick-effects' ) ) ) { section = 'tick_effects'; hasTickEffects = true; } /* WAYFARER_STORYLET_BRANCHES_PANEL_V56 */ if ( child.hasAttribute && child.hasAttribute( 'data-builder-branches-panel' ) ) { section = 'branches'; hasBranches = true; } /* * WAYFARER_LINEAR_TRAVEL_TAB_V72 * * Distance Label is existing Region travel configuration, so move * it into the Travel tab when that tab exists. Other config fields * retain the original Basics behavior. */ if ( child.hasAttribute && child.hasAttribute( 'data-config-field' ) ) { const configFieldName = child.dataset.configField || ''; const hasTravelEditor = !!form.querySelector( '[data-builder-travel-panel]' ); if ( configFieldName === 'distance_label' && hasTravelEditor ) { section = 'travel'; hasTravel = true; } else { section = 'basics'; } } /* Keep the existing Region HUD in Basics. */ if ( child.hasAttribute && child.hasAttribute( 'data-region-hud-editor' ) ) { section = 'basics'; } /* * A Media fieldset is definitive even if future markup changes * around the Media heading. */ if ( child.hasAttribute && child.hasAttribute( 'data-media-slot' ) ) { section = 'media'; hasMedia = true; } panels[section] .appendChild( child ); } ); /* * If Traits exist, anything moved after the Traits heading belongs there. * System traits do not use that heading and therefore stay in Basics. */ if ( panels.traits.children.length > 0 ) { hasTraits = true; } if ( panels.media.children.length > 0 ) { hasMedia = true; } if ( panels.conditions.children.length > 0 ) { hasConditions = true; } if ( panels.travel.children.length > 0 ) { hasTravel = true; } if ( panels.progression.children.length > 0 ) { hasProgression = true; } if ( panels.tick_effects.children.length > 0 ) { hasTickEffects = true; } if ( panels.branches.children.length > 0 ) { hasBranches = true; } const tabBar = document.createElement( 'div' ); tabBar.className = 'builder-editor-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const availablePanels = [ { key: 'basics', label: 'Basics', show: true }, { key: 'media', label: 'Media', show: hasMedia }, { key: 'traits', label: 'Traits', show: hasTraits }, { key: 'travel', label: 'Travel', show: hasTravel }, { key: 'progression', label: 'Progression', show: hasProgression }, { key: 'conditions', label: 'Conditions', show: hasConditions }, { key: 'tick_effects', label: 'Effects', show: hasTickEffects }, { key: 'branches', label: 'Branches', show: hasBranches } ].filter( function (item) { return item.show; } ); function activatePanel(panelKey) { activeTabInput.value = panelKey; tabBar .querySelectorAll( '[data-builder-tab]' ) .forEach( function (button) { const active = button.dataset.builderTab === panelKey; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); Object .entries(panels) .forEach( function (entry) { const key = entry[0]; const panel = entry[1]; panel.hidden = key !== panelKey; } ); } availablePanels.forEach( function (item) { const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'builder-editor-tab'; button.dataset.builderTab = item.key; button.setAttribute( 'role', 'tab' ); button.textContent = item.label; button.addEventListener( 'click', function () { activatePanel( item.key ); } ); tabBar.appendChild( button ); } ); /* * Insert the new structure after hidden inputs so all form fields remain * part of the same form and submit exactly as before. */ form.appendChild( tabBar ); availablePanels.forEach( function (item) { form.appendChild( panels[item.key] ); } ); if (saveContainer) { saveContainer.classList.add( 'builder-save-bar' ); form.appendChild( saveContainer ); } const initialPanelKey = availablePanels.some( function (item) { return item.key === requestedTab; } ) ? requestedTab : 'basics'; activatePanel( initialPanelKey ); } initializeBuilderEditorTabs(); // ================================================================ // SHARED GAMEPLAY TICK EFFECTS CONTROLS v25 // WAYFARER_GAMEPLAY_MODULAR_V25 // ================================================================ function initializeTickEffects() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } const wrapper = editor.querySelector( '[data-builder-tick-effects]' ); if (!wrapper) { return; } if ( wrapper.dataset.tickEffectsReady === '1' ) { return; } wrapper.dataset.tickEffectsReady = '1'; // ------------------------------------------------------------ // ROW ADD / REMOVE // ------------------------------------------------------------ const list = wrapper.querySelector( '[data-tick-effects-list]' ); const addButton = wrapper.querySelector( '[data-add-tick-effect]' ); const template = wrapper.querySelector( '[data-tick-effect-template]' ); let nextIndex = 0; wrapper .querySelectorAll( '[data-tick-effect-row]' ) .forEach( function (row) { const index = Number( row.dataset.tickIndex ); if ( Number.isFinite(index) && index >= nextIndex ) { nextIndex = index + 1; } } ); function updateLinearTargetAvailability() { const navigationSelect = editor.querySelector( 'select[name="navigation_type"]' ); const navigationType = navigationSelect ? navigationSelect.value : wrapper.dataset.navigationType; const linear = navigationType === 'linear'; wrapper .querySelectorAll( '[data-linear-navigation-target]' ) .forEach( function (option) { option.disabled = !linear; } ); if (!linear) { wrapper .querySelectorAll( '[data-tick-effect-target]' ) .forEach( function (select) { if ( select.value === 'navigation:position' ) { select.value = ''; } } ); } } if ( addButton && list && template ) { addButton.addEventListener( 'click', function () { const html = template.innerHTML.replace( /__INDEX__/g, String( nextIndex++ ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const row = temporary.firstElementChild; if (!row) { return; } list.appendChild( row ); updateLinearTargetAvailability(); } ); } wrapper.addEventListener( 'click', function (event) { const removeButton = event.target.closest( '[data-remove-tick-effect]' ); if (!removeButton) { return; } const row = removeButton.closest( '[data-tick-effect-row]' ); if (row) { row.remove(); } } ); const navigationSelect = editor.querySelector( 'select[name="navigation_type"]' ); if (navigationSelect) { navigationSelect.addEventListener( 'change', updateLinearTargetAvailability ); } updateLinearTargetAvailability(); /* * WAYFARER_EMBEDDED_TICK_EFFECTS_V33 * * Game Settings already owns a Gameplay tab. In embedded mode we keep * this shared editor exactly where PHP rendered it while retaining the * normal Tick Effect, condition, and result-branch controls. */ if ( wrapper.hasAttribute( 'data-tick-effects-embedded' ) ) { return; } // ------------------------------------------------------------ // MOVE THE SECTION INTO ITS OWN BUILDER TAB // ------------------------------------------------------------ const form = wrapper.closest( 'form' ); if (!form) { return; } const tabBar = form.querySelector( '.builder-editor-tabs' ); if (!tabBar) { /* * Builder without the v10 tab layout: * leave the Tick Effects section inline. */ return; } if ( form.querySelector( '[data-builder-panel="tick_effects"]' ) ) { return; } const panel = document.createElement( 'div' ); panel.className = 'builder-tab-panel'; panel.dataset.builderPanel = 'tick_effects'; panel.hidden = true; panel.appendChild( wrapper ); const saveBar = form.querySelector( '.builder-save-bar' ); if (saveBar) { form.insertBefore( panel, saveBar ); } else { form.appendChild( panel ); } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'builder-editor-tab'; button.dataset.builderTab = 'tick_effects'; button.setAttribute( 'role', 'tab' ); button.setAttribute( 'aria-selected', 'false' ); /* WAYFARER_GAMEPLAY_TAB_LABEL_V25_1 */ button.textContent = 'Effects'; tabBar.appendChild( button ); function activateTickEffectsTab() { tabBar .querySelectorAll( '[data-builder-tab]' ) .forEach( function (tabButton) { const active = tabButton === button; tabButton.classList.toggle( 'is-active', active ); tabButton.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); form .querySelectorAll( '[data-builder-panel]' ) .forEach( function (tabPanel) { tabPanel.hidden = tabPanel.dataset.builderPanel !== 'tick_effects'; } ); } button.addEventListener( 'click', activateTickEffectsTab ); /* * The original tab controller does not know about the panel added * afterward, so explicitly hide our panel whenever an original tab * is clicked. */ tabBar .querySelectorAll( '[data-builder-tab]:not([data-builder-tab="tick_effects"])' ) .forEach( function (originalButton) { originalButton.addEventListener( 'click', function () { panel.hidden = true; button.classList.remove( 'is-active' ); button.setAttribute( 'aria-selected', 'false' ); } ); } ); } // ================================================================ // SHARED ONE-TIME EFFECTS CONTROLS // WAYFARER_ONE_TIME_EFFECTS_EDITOR_V42 // WAYFARER_GENERALIZED_EFFECT_EDITOR_V43 // ================================================================ function wayfarerEffectConditionTargets( wrapper ) { const targetJson = wrapper.querySelector( '[data-effect-condition-targets-json]' ); if (!targetJson) { return {}; } try { return JSON.parse( targetJson.textContent || '{}' ); } catch (error) { return {}; } } /* WAYFARER_DATATYPE_EFFECT_UI_V58 */ function wayfarerEffectTargetCatalog( wrapper ) { const targetJson = wrapper ? wrapper.querySelector( '[data-effect-target-catalog-json]' ) : null; if (!targetJson) { return {}; } try { return JSON.parse( targetJson.textContent || '{}' ); } catch (error) { return {}; } } function wayfarerEffectTargetDefinition( branch ) { const effectRow = branch ? branch.closest( '[data-effect-row]' ) : null; const wrapper = effectRow ? effectRow.closest( '[data-builder-effects]' ) : null; const targetSelect = effectRow ? effectRow.querySelector( '[data-effect-target]' ) : null; const catalog = wayfarerEffectTargetCatalog( wrapper ); return targetSelect ? (catalog[targetSelect.value] || {}) : {}; } function wayfarerEffectEnsureChoiceSelect( fixedWrap, numberInput ) { if (!fixedWrap || !numberInput) { return null; } let select = fixedWrap.querySelector( '[data-effect-fixed-choice]' ); if (!select) { select = document.createElement( 'select' ); select.dataset.effectFixedChoice = '1'; select.name = numberInput.name; select.hidden = true; select.disabled = true; fixedWrap.appendChild( select ); } return select; } function wayfarerEffectBuildConditionEditor( host, name, targets ) { const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( name, targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } function wayfarerEffectUpdateResultFields( branch ) { const operation = branch.querySelector( '[data-effect-branch-operation]' ); const modeWrap = branch.querySelector( '[data-effect-value-mode-wrap]' ); const mode = branch.querySelector( '[data-effect-value-mode]' ); const fixedWrap = branch.querySelector( '[data-effect-fixed-wrap]' ); const randomWrap = branch.querySelector( '[data-effect-random-wrap]' ); if (!operation) { return; } const definition = wayfarerEffectTargetDefinition( branch ); let dataType = String( definition.data_type || definition.trait_type || 'number' ).toLowerCase(); if (dataType === 'binary') { dataType = 'boolean'; } const semanticType = String( definition.semantic_type || 'trait' ); const allowedOperations = Array.isArray( definition.effect_operations ) && definition.effect_operations.length ? definition.effect_operations : ( dataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); const supportsRandom = Object.prototype.hasOwnProperty.call( definition, 'supports_random' ) ? Boolean( definition.supports_random ) : dataType === 'number'; const operationLabels = { set: 'Set', add: semanticType === 'item_quantity' ? 'Add' : 'Add', subtract: semanticType === 'item_quantity' ? 'Subtract' : 'Subtract', none: 'No Change' }; const previousOperation = operation.value; const resultOperations = allowedOperations.concat( ['none'] ); operation.innerHTML = ''; resultOperations.forEach( function (operationValue) { const option = document.createElement( 'option' ); option.value = operationValue; option.textContent = operationLabels[operationValue] || operationValue; operation.appendChild( option ); } ); operation.value = resultOperations.includes( previousOperation ) ? previousOperation : allowedOperations[0]; const noChange = operation.value === 'none'; let valueMode = mode ? mode.value : 'fixed'; if (!supportsRandom) { valueMode = 'fixed'; if (mode) { mode.value = 'fixed'; } } if (modeWrap) { modeWrap.hidden = noChange || !supportsRandom; modeWrap .querySelectorAll( 'select, input' ) .forEach( function (field) { field.disabled = noChange || !supportsRandom; } ); } const showFixed = !noChange && ( !supportsRandom || valueMode === 'fixed' ); if (fixedWrap) { fixedWrap.hidden = !showFixed; const numberInput = fixedWrap.querySelector( 'input[name]' ); const choiceSelect = wayfarerEffectEnsureChoiceSelect( fixedWrap, numberInput ); const valueOptions = definition.value_options && typeof definition.value_options === 'object' ? definition.value_options : null; if ( showFixed && valueOptions && numberInput && choiceSelect ) { const previousValue = choiceSelect.hidden ? numberInput.value : choiceSelect.value; choiceSelect.innerHTML = ''; Object .entries(valueOptions) .forEach( function (entry) { const option = document.createElement( 'option' ); option.value = entry[0]; option.textContent = entry[1]; choiceSelect.appendChild( option ); } ); if ( Object.prototype.hasOwnProperty.call( valueOptions, String(previousValue) ) ) { choiceSelect.value = String(previousValue); } choiceSelect.hidden = false; choiceSelect.disabled = false; numberInput.hidden = true; numberInput.disabled = true; } else { if ( choiceSelect && numberInput && !choiceSelect.hidden ) { numberInput.value = choiceSelect.value; } if (choiceSelect) { choiceSelect.hidden = true; choiceSelect.disabled = true; } if (numberInput) { numberInput.hidden = !showFixed; numberInput.disabled = !showFixed; numberInput.type = dataType === 'number' ? 'number' : 'text'; if (dataType === 'number') { numberInput.step = 'any'; } else { numberInput.removeAttribute( 'step' ); } } } } if (randomWrap) { const showRandom = !noChange && supportsRandom && valueMode === 'random'; randomWrap.hidden = !showRandom; randomWrap .querySelectorAll( 'input' ) .forEach( function (field) { field.disabled = !showRandom; } ); } } function wayfarerEffectReindexBranches( effectRow ) { const effectIndex = effectRow.dataset.effectIndex; const list = effectRow.querySelector( '[data-effect-branch-list]' ); if ( effectIndex === undefined || !list ) { return; } const branches = Array .from( list.children ) .filter( function (child) { return ( child.matches && child.matches( '[data-effect-branch]' ) ); } ); branches.forEach( function ( branch, branchIndex ) { branch.dataset.branchIndex = String( branchIndex ); branch .querySelectorAll( '[name]' ) .forEach( function (field) { field.name = field.name.replace( /effects\[\d+\]\[branches\]\[\d+\]/, 'effects[' + effectIndex + '][branches][' + branchIndex + ']' ); } ); } ); } function wayfarerEffectRefreshBranchControls( effectRow ) { const list = effectRow.querySelector( '[data-effect-branch-list]' ); if (!list) { return; } const branches = Array .from( list.children ) .filter( function (child) { return ( child.matches && child.matches( '[data-effect-branch]' ) ); } ); const conditionals = branches.filter( function (branch) { return ( branch.dataset.branchKind === 'conditional' ); } ); conditionals.forEach( function ( branch, index ) { const up = branch.querySelector( '[data-move-effect-branch-up]' ); const down = branch.querySelector( '[data-move-effect-branch-down]' ); if (up) { up.disabled = index === 0; } if (down) { down.disabled = index === conditionals.length - 1; } } ); const hasOtherwise = branches.some( function (branch) { return ( branch.dataset.branchKind === 'otherwise' ); } ); const addOtherwise = effectRow.querySelector( '[data-add-effect-otherwise]' ); if (addOtherwise) { addOtherwise.disabled = hasOtherwise; } branches.forEach( wayfarerEffectUpdateResultFields ); wayfarerEffectReindexBranches( effectRow ); } function initializeEffects() { document .querySelectorAll( '[data-builder-effects]' ) .forEach( function (wrapper) { if ( wrapper.dataset.effectsReady === '1' ) { return; } wrapper.dataset.effectsReady = '1'; const list = wrapper.querySelector( '[data-effects-list]' ); const addButton = wrapper.querySelector( '[data-add-effect]' ); const effectTemplate = wrapper.querySelector( '[data-effect-template]' ); const conditionalTemplate = wrapper.querySelector( '[data-effect-conditional-template]' ); const otherwiseTemplate = wrapper.querySelector( '[data-effect-otherwise-template]' ); const conditionTargets = wayfarerEffectConditionTargets( wrapper ); let nextIndex = 0; wrapper .querySelectorAll( '[data-effect-row]' ) .forEach( function (row) { const index = Number( row.dataset.effectIndex ); if ( Number.isFinite( index ) && index >= nextIndex ) { nextIndex = index + 1; } wayfarerEffectRefreshBranchControls( row ); } ); if ( addButton && list && effectTemplate ) { addButton.addEventListener( 'click', function () { const effectIndex = nextIndex++; const html = effectTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', String( effectIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const row = temporary.firstElementChild; if (!row) { return; } list.appendChild( row ); wayfarerEffectRefreshBranchControls( row ); } ); } wrapper.addEventListener( 'change', function (event) { const target = event.target.closest( '[data-effect-target]' ); if (target) { const effectRow = target.closest( '[data-effect-row]' ); if (effectRow) { wayfarerEffectRefreshBranchControls( effectRow ); } return; } const operation = event.target.closest( '[data-effect-branch-operation]' ); const valueMode = event.target.closest( '[data-effect-value-mode]' ); if ( !operation && !valueMode ) { return; } const branch = event.target.closest( '[data-effect-branch]' ); if (branch) { wayfarerEffectUpdateResultFields( branch ); } } ); wrapper.addEventListener( 'click', function (event) { const effectRow = event.target.closest( '[data-effect-row]' ); const removeEffect = event.target.closest( '[data-remove-effect]' ); if (removeEffect) { if (!effectRow) { return; } if ( !window.confirm( 'Remove this Effect?' ) ) { return; } effectRow.remove(); return; } if (!effectRow) { return; } const addConditional = event.target.closest( '[data-add-effect-conditional]' ); if (addConditional) { if (!conditionalTemplate) { return; } const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if (!branchList) { return; } const effectIndex = effectRow.dataset.effectIndex; const branchIndex = branchList .querySelectorAll( ':scope > [data-effect-branch]' ) .length; const html = conditionalTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } const otherwise = Array .from( branchList.children ) .find( function (child) { return ( child.dataset && child.dataset.branchKind === 'otherwise' ); } ); if (otherwise) { branchList.insertBefore( branch, otherwise ); } else { branchList.appendChild( branch ); } wayfarerEffectReindexBranches( effectRow ); const newIndex = branch.dataset.branchIndex; const host = branch.querySelector( '[data-new-effect-condition-editor]' ); if (host) { wayfarerEffectBuildConditionEditor( host, 'effects[' + effectIndex + '][branches][' + newIndex + '][conditions]', conditionTargets ); } wayfarerEffectRefreshBranchControls( effectRow ); return; } const addOtherwise = event.target.closest( '[data-add-effect-otherwise]' ); if (addOtherwise) { if (!otherwiseTemplate) { return; } const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if ( !branchList || branchList.querySelector( '[data-branch-kind="otherwise"]' ) ) { return; } const effectIndex = effectRow.dataset.effectIndex; const branchIndex = branchList .querySelectorAll( ':scope > [data-effect-branch]' ) .length; const html = otherwiseTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } branchList.appendChild( branch ); wayfarerEffectRefreshBranchControls( effectRow ); return; } const removeBranch = event.target.closest( '[data-remove-effect-branch]' ); if (removeBranch) { const branch = removeBranch.closest( '[data-effect-branch]' ); if (branch) { branch.remove(); wayfarerEffectRefreshBranchControls( effectRow ); } return; } const moveUp = event.target.closest( '[data-move-effect-branch-up]' ); const moveDown = event.target.closest( '[data-move-effect-branch-down]' ); if ( !moveUp && !moveDown ) { return; } const branch = event.target.closest( '[data-effect-branch]' ); const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if ( !branch || !branchList || branch.dataset.branchKind !== 'conditional' ) { return; } const conditionals = Array .from( branchList.children ) .filter( function (child) { return ( child.dataset && child.dataset.branchKind === 'conditional' ); } ); const index = conditionals.indexOf( branch ); if ( moveUp && index > 0 ) { branchList.insertBefore( branch, conditionals[ index - 1 ] ); } if ( moveDown && index >= 0 && index < conditionals.length - 1 ) { const next = conditionals[ index + 1 ]; branchList.insertBefore( next, branch ); } wayfarerEffectRefreshBranchControls( effectRow ); } ); } ); } initializeEffects(); initializeTickEffects();initializeTickEffects(); // ================================================================ // REUSABLE CONDITION EDITOR v27 // WAYFARER_CONDITION_EDITOR_V27 // ================================================================ function wayfarerConditionEscape(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function wayfarerConditionTargetOptions( targets ) { const groups = {}; Object .entries( targets || {} ) .forEach( function (entry) { const key = entry[0]; const target = entry[1] || {}; /* * WAYFARER_LOCATION_CONDITION_TARGETS_V75 * WAYFARER_BUILDER_JS_REPAIR_V79 * * Hidden compatibility targets are retained in JSON so an * existing saved row can resolve its datatype, but they are * not offered when the author adds a new Condition. */ if (target.condition_hidden) { return; } const group = target.group || 'Targets'; if (!groups[group]) { groups[group] = []; } const helper = String( target.helper || '' ).trim(); const baseLabel = target.label || key; groups[group].push({ key: key, label: helper !== '' ? baseLabel + ' — ' + helper : baseLabel }); } ); let html = ''; Object .entries(groups) .forEach( function (entry) { html += ''; entry[1] .forEach( function (target) { html += ''; } ); html += ''; } ); return html; } /* WAYFARER_DATATYPE_CONDITION_UI_V58 */ function wayfarerConditionOperatorLabel( operator ) { const labels = { equals: 'Equals', not_equals: 'Does not equal', greater_than: 'Greater than', greater_than_or_equal: 'Greater than or equal', less_than: 'Less than', less_than_or_equal: 'Less than or equal' }; return labels[operator] || operator; } function wayfarerConditionUpdateRow( row, targets ) { if (!row) { return; } const targetSelect = row.querySelector( '[data-condition-target]' ); const operatorSelect = row.querySelector( '[data-condition-operator]' ); const valueInput = row.querySelector( '[data-condition-value-input]' ); const valueSelect = row.querySelector( '[data-condition-value-select]' ); const valueWrap = row.querySelector( '[data-condition-value-wrap]' ); if (!targetSelect || !operatorSelect) { return; } const definition = (targets || {})[targetSelect.value] || {}; const dataType = String( definition.data_type || definition.trait_type || 'text' ).toLowerCase() === 'binary' ? 'boolean' : String( definition.data_type || definition.trait_type || 'text' ).toLowerCase(); const allowedOperators = Array.isArray( definition.condition_operators ) && definition.condition_operators.length ? definition.condition_operators : [ 'equals', 'not_equals', 'greater_than', 'greater_than_or_equal', 'less_than', 'less_than_or_equal' ]; const previousOperator = operatorSelect.value; operatorSelect.innerHTML = ''; allowedOperators.forEach( function (operator) { const option = document.createElement( 'option' ); option.value = operator; const operatorLabels = definition.condition_operator_labels && typeof definition.condition_operator_labels === 'object' ? definition.condition_operator_labels : {}; option.textContent = operatorLabels[operator] || wayfarerConditionOperatorLabel( operator ); operatorSelect.appendChild( option ); } ); operatorSelect.value = allowedOperators.includes( previousOperator ) ? previousOperator : allowedOperators[0]; const valueOptions = definition.value_options && typeof definition.value_options === 'object' ? definition.value_options : null; if (valueWrap) { valueWrap.hidden = Boolean( definition.condition_hide_value ); } if (valueOptions && valueSelect) { const previousValue = valueSelect.hidden && valueInput ? valueInput.value : valueSelect.value; valueSelect.innerHTML = ''; Object .entries(valueOptions) .forEach( function (entry) { const option = document.createElement( 'option' ); option.value = entry[0]; option.textContent = entry[1]; valueSelect.appendChild( option ); } ); if ( Object.prototype.hasOwnProperty.call( valueOptions, String(previousValue) ) ) { valueSelect.value = String(previousValue); } valueSelect.hidden = false; valueSelect.disabled = false; if (valueInput) { valueInput.hidden = true; valueInput.disabled = true; } return; } if ( valueSelect && valueInput && !valueSelect.hidden ) { valueInput.value = valueSelect.value; } if (valueSelect) { valueSelect.hidden = true; valueSelect.disabled = true; } if (valueInput) { valueInput.hidden = false; valueInput.disabled = false; valueInput.type = dataType === 'number' ? 'number' : 'text'; if (dataType === 'number') { valueInput.step = 'any'; } else { valueInput.removeAttribute( 'step' ); } } } function wayfarerConditionNextChildIndex( group ) { let maxIndex = -1; const children = Array .from( group.children ) .find( function (child) { return child.hasAttribute( 'data-condition-children' ); } ); if (children) { Array .from( children.children ) .forEach( function (node) { let index = Number( node.dataset.conditionChildIndex ); if (!Number.isFinite(index)) { const namedInput = node.querySelector( '[name]' ); const match = namedInput ? namedInput.name.match( /\[children\]\[(\d+)\]/ ) : null; index = match ? Number(match[1]) : -1; } if ( Number.isFinite(index) && index > maxIndex ) { maxIndex = index; } } ); } return maxIndex + 1; } function wayfarerConditionCreateCondition( name, targets, childIndex ) { const row = document.createElement( 'div' ); row.className = 'builder-condition-row'; row.dataset.conditionNode = '1'; row.dataset.conditionType = 'condition'; row.dataset.conditionChildIndex = String(childIndex); row.innerHTML = '' + '' + '' + '' + ''; return row; } function wayfarerConditionCreateGroup( name, targets, childIndex, isRoot ) { const group = document.createElement( 'div' ); group.className = 'builder-condition-group' + ( isRoot ? ' builder-condition-group-root' : '' ); group.dataset.conditionNode = '1'; group.dataset.conditionType = 'group'; if ( childIndex !== null && childIndex !== undefined ) { group.dataset.conditionChildIndex = String(childIndex); } group.innerHTML = '' + '

' + '' + ' of the following' + ( isRoot ? '' : '' ) + '
' + '
' + '
' + ' ' + '' + '
'; return group; } function wayfarerConditionInitializeEditor( editor, targets ) { if ( !editor || editor.dataset.conditionEditorReady === '1' ) { return; } editor.dataset.conditionEditorReady = '1'; editor.addEventListener( 'change', function (event) { const targetSelect = event.target.closest( '[data-condition-target]' ); if (!targetSelect) { return; } wayfarerConditionUpdateRow( targetSelect.closest( '[data-condition-type="condition"]' ), targets ); } ); editor .querySelectorAll( '[data-condition-type="condition"]' ) .forEach( function (row) { wayfarerConditionUpdateRow( row, targets ); } ); editor.addEventListener( 'click', function (event) { const removeButton = event.target.closest( '[data-remove-condition-node]' ); if (removeButton) { const node = removeButton.closest( '[data-condition-node]' ); if (node) { node.remove(); } return; } const addCondition = event.target.closest( '[data-add-condition]' ); const addGroup = event.target.closest( '[data-add-condition-group]' ); if ( !addCondition && !addGroup ) { return; } const group = event.target.closest( '[data-condition-type="group"]' ); if (!group) { return; } const children = Array .from( group.children ) .find( function (child) { return child.hasAttribute( 'data-condition-children' ); } ); if (!children) { return; } const groupTypeInput = Array .from( group.children ) .find( function (child) { return ( child.tagName === 'INPUT' && child.type === 'hidden' && child.name.endsWith( '[type]' ) ); } ); if (!groupTypeInput) { return; } const groupName = groupTypeInput.name.replace( /\[type\]$/, '' ); const nextIndex = wayfarerConditionNextChildIndex( group ); const childName = groupName + '[children][' + nextIndex + ']'; const newNode = addGroup ? wayfarerConditionCreateGroup( childName, targets, nextIndex, false ) : wayfarerConditionCreateCondition( childName, targets, nextIndex ); children.appendChild( newNode ); if (newNode.dataset.conditionType === 'condition') { wayfarerConditionUpdateRow( newNode, targets ); } } ); } function initializeConditionEditors() { /* * WAYFARER_GENERIC_CONDITION_INIT_V45_2 * * The Condition Editor is shared infrastructure. It must work anywhere it * is rendered, not only when nested inside Tick Effects. * * Server-rendered editors carry their own allowed-target JSON. Dynamic * Tick Effect / Effect editors continue to initialize themselves through * their existing creation paths. */ document .querySelectorAll( '[data-condition-editor]' ) .forEach( function (editor) { let targets = {}; const targetJson = editor.querySelector( '[data-condition-editor-targets-json]' ); if (targetJson) { try { targets = JSON.parse( targetJson.textContent || '{}' ); } catch (error) { targets = {}; } } wayfarerConditionInitializeEditor( editor, targets ); } ); /* * Tick Effects retain their observer because newly-created Tick Effect * rows are added after page load and are not server-rendered editors. */ document .querySelectorAll( '[data-builder-tick-effects]' ) .forEach( function (wrapper) { let targets = {}; const targetJson = wrapper.querySelector( '[data-condition-targets-json]' ); if (targetJson) { try { targets = JSON.parse( targetJson.textContent || '{}' ); } catch (error) { targets = {}; } } wrapper .querySelectorAll( '[data-condition-editor]' ) .forEach( function (editor) { wayfarerConditionInitializeEditor( editor, targets ); } ); /* * New Tick Effect rows are cloned from a template after page * load, so convert their placeholder into a fresh root group. */ const list = wrapper.querySelector( '[data-tick-effects-list]' ); if ( list && !list.dataset.conditionObserverReady ) { list.dataset.conditionObserverReady = '1'; const observer = new MutationObserver( function (mutations) { mutations.forEach( function (mutation) { mutation.addedNodes .forEach( function (addedNode) { if ( !addedNode.querySelectorAll ) { return; } const row = addedNode.matches && addedNode.matches( '[data-tick-effect-row]' ) ? addedNode : addedNode.querySelector( '[data-tick-effect-row]' ); if (!row) { return; } const host = row.querySelector( '[data-new-condition-editor]' ); if (!host) { return; } const index = row.dataset.tickIndex; const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( 'tick_effects[' + index + '][conditions]', targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } ); } ); } ); observer.observe( list, { childList: true, subtree: true } ); } } ); } initializeConditionEditors(); // ================================================================ // TICK EFFECT RESULT BRANCHES v28 // WAYFARER_TICK_EFFECT_BRANCHES_V28 // ================================================================ function wayfarerTickEffectConditionTargets( wrapper ) { const targetJson = wrapper.querySelector( '[data-condition-targets-json]' ); if (!targetJson) { return {}; } try { return JSON.parse( targetJson.textContent || '{}' ); } catch (error) { return {}; } } /* WAYFARER_DATATYPE_TICK_EFFECT_UI_V58 */ function wayfarerTickEffectTargetCatalog( wrapper ) { const targetJson = wrapper ? wrapper.querySelector( '[data-tick-target-catalog-json]' ) : null; if (!targetJson) { return {}; } try { return JSON.parse( targetJson.textContent || '{}' ); } catch (error) { return {}; } } function wayfarerTickEffectTargetDefinition( branch ) { const effectRow = branch ? branch.closest( '[data-tick-effect-row]' ) : null; const wrapper = effectRow ? effectRow.closest( '[data-builder-tick-effects]' ) : null; const targetSelect = effectRow ? effectRow.querySelector( '[data-tick-effect-target]' ) : null; const catalog = wayfarerTickEffectTargetCatalog( wrapper ); return targetSelect ? (catalog[targetSelect.value] || {}) : {}; } function wayfarerTickEffectEnsureValueMode( branch, valueInput ) { if (!branch || !valueInput) { return null; } let wrap = branch.querySelector( '[data-tick-value-mode-wrap]' ); if (!wrap) { wrap = document.createElement( 'label' ); wrap.dataset.tickValueModeWrap = '1'; wrap.appendChild( document.createTextNode( 'Value Type' ) ); const select = document.createElement( 'select' ); select.dataset.tickValueMode = '1'; select.name = valueInput.name.replace( /\[value\]$/, '[value_mode]' ); select.innerHTML = '' + ''; const fields = valueInput.closest( '.builder-tick-result-fields' ); if ( fields && fields.dataset.tickInitialValueMode === 'random' ) { select.value = 'random'; } wrap.appendChild( select ); const valueWrap = branch.querySelector( '[data-tick-branch-value-wrap]' ); if (valueWrap && valueWrap.parentNode) { valueWrap.parentNode.insertBefore( wrap, valueWrap ); } } return wrap; } function wayfarerTickEffectEnsureChoiceSelect( valueWrap, valueInput ) { if (!valueWrap || !valueInput) { return null; } let select = valueWrap.querySelector( '[data-tick-fixed-choice]' ); if (!select) { select = document.createElement( 'select' ); select.dataset.tickFixedChoice = '1'; select.name = valueInput.name; select.hidden = true; select.disabled = true; valueWrap.appendChild( select ); } return select; } function wayfarerTickEffectEnsureRandomWrap( branch, valueInput ) { if (!branch || !valueInput) { return null; } let wrap = branch.querySelector( '[data-tick-random-wrap]' ); if (!wrap) { wrap = document.createElement( 'div' ); wrap.className = 'builder-effect-random-range'; wrap.dataset.tickRandomWrap = '1'; const baseName = valueInput.name.replace( /\[value\]$/, '' ); wrap.innerHTML = '' + ''; const fields = valueInput.closest( '.builder-tick-result-fields' ); if (fields) { const minInput = wrap.querySelector( '[data-tick-random-min]' ); const maxInput = wrap.querySelector( '[data-tick-random-max]' ); if (minInput) { minInput.value = fields.dataset.tickInitialMin || ''; } if (maxInput) { maxInput.value = fields.dataset.tickInitialMax || ''; } } const valueWrap = branch.querySelector( '[data-tick-branch-value-wrap]' ); if (valueWrap && valueWrap.parentNode) { valueWrap.parentNode.insertBefore( wrap, valueWrap.nextSibling ); } } return wrap; } function wayfarerTickEffectUpdateBranchValue( branch ) { const operation = branch.querySelector( '[data-tick-branch-operation]' ); const valueWrap = branch.querySelector( '[data-tick-branch-value-wrap]' ); if ( !operation || !valueWrap ) { return; } const input = valueWrap.querySelector( 'input[name]' ); if (!input) { return; } const definition = wayfarerTickEffectTargetDefinition( branch ); let dataType = String( definition.data_type || definition.trait_type || 'number' ).toLowerCase(); if (dataType === 'binary') { dataType = 'boolean'; } const allowedOperations = Array.isArray( definition.effect_operations ) && definition.effect_operations.length ? definition.effect_operations : ( dataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); const supportsRandom = Object.prototype.hasOwnProperty.call( definition, 'supports_random' ) ? Boolean( definition.supports_random ) : dataType === 'number'; const previousOperation = operation.value; const resultOperations = allowedOperations.concat( ['none'] ); operation.innerHTML = ''; const operationLabels = { set: 'Set', add: 'Add', subtract: 'Subtract', none: 'No Change' }; resultOperations.forEach( function (operationValue) { const option = document.createElement( 'option' ); option.value = operationValue; option.textContent = operationLabels[operationValue] || operationValue; operation.appendChild( option ); } ); operation.value = resultOperations.includes( previousOperation ) ? previousOperation : allowedOperations[0]; const noChange = operation.value === 'none'; const modeWrap = wayfarerTickEffectEnsureValueMode( branch, input ); const mode = modeWrap ? modeWrap.querySelector( '[data-tick-value-mode]' ) : null; let valueMode = mode ? mode.value : 'fixed'; if (!supportsRandom) { valueMode = 'fixed'; if (mode) { mode.value = 'fixed'; } } if (modeWrap) { modeWrap.hidden = noChange || !supportsRandom; if (mode) { mode.disabled = noChange || !supportsRandom; } } const showFixed = !noChange && ( !supportsRandom || valueMode === 'fixed' ); valueWrap.hidden = !showFixed; const choiceSelect = wayfarerTickEffectEnsureChoiceSelect( valueWrap, input ); const valueOptions = definition.value_options && typeof definition.value_options === 'object' ? definition.value_options : null; if ( showFixed && valueOptions && choiceSelect ) { const previousValue = choiceSelect.hidden ? input.value : choiceSelect.value; choiceSelect.innerHTML = ''; Object .entries(valueOptions) .forEach( function (entry) { const option = document.createElement( 'option' ); option.value = entry[0]; option.textContent = entry[1]; choiceSelect.appendChild( option ); } ); if ( Object.prototype.hasOwnProperty.call( valueOptions, String(previousValue) ) ) { choiceSelect.value = String(previousValue); } choiceSelect.hidden = false; choiceSelect.disabled = false; input.hidden = true; input.disabled = true; } else { if ( choiceSelect && !choiceSelect.hidden ) { input.value = choiceSelect.value; } if (choiceSelect) { choiceSelect.hidden = true; choiceSelect.disabled = true; } input.hidden = !showFixed; input.disabled = !showFixed; input.type = dataType === 'number' ? 'number' : 'text'; if (dataType === 'number') { input.step = 'any'; } else { input.removeAttribute( 'step' ); } } const randomWrap = wayfarerTickEffectEnsureRandomWrap( branch, input ); if (randomWrap) { const showRandom = !noChange && supportsRandom && valueMode === 'random'; randomWrap.hidden = !showRandom; randomWrap .querySelectorAll( 'input' ) .forEach( function (field) { field.disabled = !showRandom; } ); } } function wayfarerTickEffectReindexBranches( effectRow ) { const effectIndex = effectRow.dataset.tickIndex; const list = effectRow.querySelector( '[data-tick-branch-list]' ); if ( effectIndex === undefined || !list ) { return; } const branches = Array.from( list.children ).filter( function (child) { return ( child.matches && child.matches( '[data-tick-effect-branch]' ) ); } ); branches.forEach( function ( branch, branchIndex ) { branch.dataset.branchIndex = String( branchIndex ); branch .querySelectorAll( '[name]' ) .forEach( function (field) { field.name = field.name.replace( /tick_effects\[\d+\]\[branches\]\[\d+\]/, 'tick_effects[' + effectIndex + '][branches][' + branchIndex + ']' ); } ); } ); } function wayfarerTickEffectRefreshBranchControls( effectRow ) { const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } const branches = Array.from( list.children ).filter( function (child) { return ( child.matches && child.matches( '[data-tick-effect-branch]' ) ); } ); const conditionalBranches = branches.filter( function (branch) { return ( branch.dataset.branchKind === 'conditional' ); } ); conditionalBranches.forEach( function ( branch, index ) { const up = branch.querySelector( '[data-move-tick-branch-up]' ); const down = branch.querySelector( '[data-move-tick-branch-down]' ); if (up) { up.disabled = index === 0; } if (down) { down.disabled = index === conditionalBranches.length - 1; } } ); const hasOtherwise = branches.some( function (branch) { return ( branch.dataset.branchKind === 'otherwise' ); } ); const addOtherwise = effectRow.querySelector( '[data-add-tick-otherwise-result]' ); if (addOtherwise) { addOtherwise.disabled = hasOtherwise; } branches.forEach( wayfarerTickEffectUpdateBranchValue ); wayfarerTickEffectReindexBranches( effectRow ); } function wayfarerTickEffectBuildConditionEditor( host, name, targets ) { const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( name, targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } function initializeTickEffectResultBranches() { document .querySelectorAll( '[data-builder-tick-effects]' ) .forEach( function (wrapper) { if ( wrapper.dataset.tickBranchesReady === '1' ) { return; } wrapper.dataset.tickBranchesReady = '1'; const conditionalTemplate = wrapper.querySelector( '[data-tick-conditional-branch-template]' ); const otherwiseTemplate = wrapper.querySelector( '[data-tick-otherwise-branch-template]' ); const conditionTargets = wayfarerTickEffectConditionTargets( wrapper ); wrapper .querySelectorAll( '[data-tick-effect-row]' ) .forEach( wayfarerTickEffectRefreshBranchControls ); wrapper.addEventListener( 'change', function (event) { const target = event.target.closest( '[data-tick-effect-target]' ); if (target) { const effectRow = target.closest( '[data-tick-effect-row]' ); if (effectRow) { wayfarerTickEffectRefreshBranchControls( effectRow ); } return; } const operation = event.target.closest( '[data-tick-branch-operation]' ); const valueMode = event.target.closest( '[data-tick-value-mode]' ); if (!operation && !valueMode) { return; } const branch = event.target.closest( '[data-tick-effect-branch]' ); if (branch) { wayfarerTickEffectUpdateBranchValue( branch ); } } ); wrapper.addEventListener( 'click', function (event) { const effectRow = event.target.closest( '[data-tick-effect-row]' ); if (!effectRow) { return; } const addConditional = event.target.closest( '[data-add-tick-conditional-result]' ); if (addConditional) { if (!conditionalTemplate) { return; } const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } const effectIndex = effectRow.dataset.tickIndex; const branchIndex = list.querySelectorAll( ':scope > [data-tick-effect-branch]' ).length; const html = conditionalTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } const otherwise = Array .from( list.children ) .find( function (child) { return ( child.dataset && child.dataset.branchKind === 'otherwise' ); } ); if (otherwise) { list.insertBefore( branch, otherwise ); } else { list.appendChild( branch ); } wayfarerTickEffectReindexBranches( effectRow ); const newIndex = branch.dataset.branchIndex; const host = branch.querySelector( '[data-new-branch-condition-editor]' ); if (host) { wayfarerTickEffectBuildConditionEditor( host, 'tick_effects[' + effectIndex + '][branches][' + newIndex + '][conditions]', conditionTargets ); } wayfarerTickEffectRefreshBranchControls( effectRow ); return; } const addOtherwise = event.target.closest( '[data-add-tick-otherwise-result]' ); if (addOtherwise) { if (!otherwiseTemplate) { return; } const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } if ( list.querySelector( '[data-branch-kind="otherwise"]' ) ) { return; } const effectIndex = effectRow.dataset.tickIndex; const branchIndex = list.querySelectorAll( ':scope > [data-tick-effect-branch]' ).length; const html = otherwiseTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } list.appendChild( branch ); wayfarerTickEffectRefreshBranchControls( effectRow ); return; } const removeBranch = event.target.closest( '[data-remove-tick-branch]' ); if (removeBranch) { const branch = removeBranch.closest( '[data-tick-effect-branch]' ); if (branch) { branch.remove(); wayfarerTickEffectRefreshBranchControls( effectRow ); } return; } const moveUp = event.target.closest( '[data-move-tick-branch-up]' ); const moveDown = event.target.closest( '[data-move-tick-branch-down]' ); if ( !moveUp && !moveDown ) { return; } const branch = event.target.closest( '[data-tick-effect-branch]' ); const list = effectRow.querySelector( '[data-tick-branch-list]' ); if ( !branch || !list || branch.dataset.branchKind !== 'conditional' ) { return; } const conditionals = Array.from( list.children ).filter( function (child) { return ( child.dataset && child.dataset.branchKind === 'conditional' ); } ); const index = conditionals.indexOf( branch ); if ( moveUp && index > 0 ) { list.insertBefore( branch, conditionals[index - 1] ); } if ( moveDown && index >= 0 && index < conditionals.length - 1 ) { const next = conditionals[index + 1]; list.insertBefore( next, branch ); } wayfarerTickEffectRefreshBranchControls( effectRow ); } ); /* * Existing Tick Effect code creates whole effect rows from its * template. Refresh branch controls after those rows appear. */ const list = wrapper.querySelector( '[data-tick-effects-list]' ); if (list) { const observer = new MutationObserver( function (mutations) { mutations.forEach( function (mutation) { mutation.addedNodes .forEach( function (node) { if ( !node.matches || !node.matches( '[data-tick-effect-row]' ) ) { return; } wayfarerTickEffectRefreshBranchControls( node ); } ); } ); } ); observer.observe( list, { childList: true } ); } } ); } initializeTickEffectResultBranches(); ================================================================================ FILE: assets/js/game-endings.js ================================================================================ /* |-------------------------------------------------------------------------- | Game Endings Editor | WAYFARER_GAME_ENDINGS_V45 |-------------------------------------------------------------------------- */ (function () { 'use strict'; const editor = document.querySelector( '[data-game-endings-editor]' ); if (!editor) { return; } const list = editor.querySelector( '[data-game-endings-list]' ); const template = editor.querySelector( '[data-game-ending-template]' ); const addButton = editor.querySelector( '[data-add-game-ending]' ); const targetJson = editor.querySelector( '[data-game-ending-condition-targets]' ); let targets = {}; if (targetJson) { try { targets = JSON.parse( targetJson.textContent || '{}' ); } catch (error) { targets = {}; } } function reindex() { if (!list) { return; } Array .from( list.querySelectorAll( ':scope > [data-game-ending]' ) ) .forEach( function ( ending, index ) { ending.dataset.endingIndex = String(index); ending .querySelectorAll( '[name]' ) .forEach( function (field) { field.name = field.name.replace( /endings\[\d+\]/, 'endings[' + index + ']' ); } ); } ); } if ( addButton && list && template ) { addButton.addEventListener( 'click', function () { const index = list.querySelectorAll( ':scope > [data-game-ending]' ).length; const html = template.innerHTML.replaceAll( '__INDEX__', String(index) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const ending = temporary.firstElementChild; if (!ending) { return; } list.appendChild( ending ); const host = ending.querySelector( '[data-new-ending-condition-editor]' ); if ( host && typeof wayfarerConditionCreateGroup === 'function' && typeof wayfarerConditionInitializeEditor === 'function' ) { const conditionEditor = document.createElement( 'div' ); conditionEditor.className = 'builder-condition-editor'; conditionEditor.dataset.conditionEditor = '1'; conditionEditor.appendChild( wayfarerConditionCreateGroup( 'endings[' + index + '][conditions]', targets, null, true ) ); host.replaceWith( conditionEditor ); wayfarerConditionInitializeEditor( conditionEditor, targets ); } reindex(); } ); } editor.addEventListener( 'click', function (event) { const removeButton = event.target.closest( '[data-remove-game-ending]' ); if (!removeButton) { return; } const ending = removeButton.closest( '[data-game-ending]' ); if (!ending) { return; } if ( !window.confirm( 'Remove this Ending?' ) ) { return; } ending.remove(); reindex(); } ); reindex(); })(); ================================================================================ FILE: assets/js/game-travel.js ================================================================================ /* * WAYFARER_GAME_TRAVEL_MODIFIERS_JS_V73 * WAYFARER_GENERIC_TRAVEL_MODIFIER_COPY_V74 * Dynamic Game Settings Travel Modifier cards. */ (function () { 'use strict'; const host = document.querySelector( '[data-game-travel-modifiers]' ); if (!host) { return; } const list = host.querySelector( '[data-game-travel-modifier-list]' ); const addButton = host.querySelector( '[data-add-game-travel-modifier]' ); const targetJson = host.querySelector( '[data-game-travel-targets-json]' ); if (!list || !addButton) { return; } let targets = {}; if (targetJson) { try { targets = JSON.parse( targetJson.textContent || '{}' ); } catch (error) { targets = {}; } } function nextIndex() { const current = Number( host.dataset.nextIndex || 0 ); const index = Number.isFinite(current) ? current : 0; host.dataset.nextIndex = String(index + 1); return index; } function refreshLegend(card) { const legend = card.querySelector( '[data-game-travel-modifier-legend]' ); const name = card.querySelector( '[data-game-travel-modifier-name]' ); if (!legend || !name) { return; } const value = String( name.value || '' ).trim(); legend.textContent = value !== '' ? value : 'Travel Modifier'; } host.addEventListener( 'input', function (event) { const name = event.target.closest( '[data-game-travel-modifier-name]' ); if (!name) { return; } const card = name.closest( '[data-game-travel-modifier]' ); if (card) { refreshLegend(card); } } ); host.addEventListener( 'click', function (event) { const removeButton = event.target.closest( '[data-remove-game-travel-modifier]' ); if (!removeButton) { return; } const card = removeButton.closest( '[data-game-travel-modifier]' ); if ( card && window.confirm( 'Remove this Travel Modifier?' ) ) { card.remove(); } } ); addButton.addEventListener( 'click', function () { const index = nextIndex(); const card = document.createElement( 'fieldset' ); card.dataset.gameTravelModifier = '1'; card.dataset.travelModifierIndex = String(index); card.innerHTML = 'Travel Modifier' + '' + '

' + '


Applied to the current Region\'s Base Distance Per Tick.

' + '

Apply when

' + '

No Conditions means this modifier always applies during dedicated Linear Travel.

' + '
' + '

'; const conditionEditor = card.querySelector( '[data-condition-editor]' ); if ( conditionEditor && typeof window.wayfarerConditionCreateGroup === 'function' ) { conditionEditor.appendChild( window.wayfarerConditionCreateGroup( 'travel_modifiers[' + index + '][conditions]', targets, null, true ) ); if ( typeof window.wayfarerConditionInitializeEditor === 'function' ) { window.wayfarerConditionInitializeEditor( conditionEditor, targets ); } } list.appendChild( card ); } ); })(); ================================================================================ FILE: assets/js/hierarchical-targets.js ================================================================================ /* * Wayfarer Hierarchical Target Picker * WAYFARER_HIERARCHICAL_TARGETS_V80 * * Progressive enhancement: * - The existing canonical . * Because that select lives inside the original Target

================================================================================ FILE: includes/builder/functions.php ================================================================================ prepare($sql); $stmt->execute([$gameId]); return $stmt->fetchAll(); } function buildScopeQuery(array $definition): array { $table = $definition['table']; $scope = $definition['scope']; if ($scope['type'] === 'game') { return [ 'from' => "`$table`", 'where' => "`$table`.`{$scope['field']}` = ?", ]; } $scopeTable = $scope['scope_table']; $scopeField = $scope['field']; $scopeKey = $scope['scope_key']; $gameField = $scope['game_field']; return [ 'from' => " `$table` INNER JOIN `$scopeTable` ON `$table`.`$scopeField` = `$scopeTable`.`$scopeKey` ", 'where' => "`$scopeTable`.`$gameField` = ?", ]; } function getRelatedFieldValue( PDO $pdo, array $condition, $relationshipValue ) { if ($relationshipValue === '' || $relationshipValue === null) { return null; } $table = $condition['source_table']; $sourceKey = $condition['source_key']; $sourceField = $condition['source_field']; $sql = " SELECT `$sourceField` FROM `$table` WHERE `$sourceKey` = ? LIMIT 1 "; $stmt = $pdo->prepare($sql); $stmt->execute([$relationshipValue]); $value = $stmt->fetchColumn(); return $value === false ? null : $value; } function configFieldApplies( PDO $pdo, array $field, array $record ): bool { if (empty($field['condition'])) { return true; } $condition = $field['condition']; $relationshipName = $condition['relationship'] ?? null; if (!$relationshipName) { return false; } $relationshipValue = $record[$relationshipName] ?? ''; $sourceValue = getRelatedFieldValue( $pdo, $condition, $relationshipValue ); return (string)$sourceValue === (string)$condition['equals']; } function builderEscape($value): string { return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); } /* |-------------------------------------------------------------------------- | DELETE BUILDER RECORD |-------------------------------------------------------------------------- | | Permanently removes one authored record after: | | 1. verifying it belongs to the current game; | 2. checking configured dependencies; | 3. allowing the database to reject any other protected references. | | This is intentionally generic. Builder types describe their own | dependency rules in config/builder-types.php. | */ function deleteBuilderRecord( PDO $pdo, array $definition, int $gameId, int $id ): array { $deleteDefinition = $definition['delete'] ?? []; if (empty($deleteDefinition['enabled'])) { return [ 'deleted' => false, 'error' => 'Deletion is not enabled for this type.' ]; } if ($id <= 0) { return [ 'deleted' => false, 'error' => 'A valid record ID is required.' ]; } $table = $definition['table']; $listField = $definition['list_field']; $singular = $definition['singular'] ?? 'record'; $scopeQuery = buildScopeQuery($definition); /* * Verify the record belongs to this game before doing anything else. */ $sql = " SELECT `$table`.`id`, `$table`.`$listField` AS record_label, `$table`.`config` AS record_config FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($sql); $stmt->execute([ $id, $gameId ]); $record = $stmt->fetch(); if (!$record) { return [ 'deleted' => false, 'error' => $singular . ' not found.' ]; } $recordLabel = (string)($record['record_label'] ?? ''); /* * Check dependency rules declared by this builder type. */ foreach ( ($deleteDefinition['dependencies'] ?? []) as $dependency ) { $dependencyTable = $dependency['table'] ?? ''; $foreignKey = $dependency['foreign_key'] ?? ''; $dependencyLabel = $dependency['label'] ?? 'related records'; if ( !preg_match('/^[A-Za-z0-9_]+$/', $dependencyTable) || !preg_match('/^[A-Za-z0-9_]+$/', $foreignKey) ) { return [ 'deleted' => false, 'error' => 'The delete dependency configuration for this ' . $singular . ' is invalid.' ]; } $dependencySql = " SELECT COUNT(*) FROM `$dependencyTable` WHERE `$foreignKey` = ? "; $dependencyStmt = $pdo->prepare($dependencySql); $dependencyStmt->execute([ $id ]); $dependencyCount = (int)$dependencyStmt->fetchColumn(); if ($dependencyCount > 0) { $namePart = $recordLabel !== '' ? ' "' . $recordLabel . '"' : ''; return [ 'deleted' => false, 'error' => 'Cannot delete ' . $singular . $namePart . '. It still has ' . $dependencyCount . ' ' . $dependencyLabel . '. Move or delete those first.' ]; } } /* * Delete only after all explicit dependency checks have passed. * * A database foreign-key constraint may still block deletion if a * dependency has not yet been described in builder-types.php. In that * case, report the failure rather than crashing the builder. */ try { $deleteSql = " DELETE FROM `$table` WHERE id = ? LIMIT 1 "; $deleteStmt = $pdo->prepare($deleteSql); $deleteStmt->execute([ $id ]); } catch (PDOException $e) { return [ 'deleted' => false, 'error' => 'This ' . $singular . ' could not be deleted because another record still ' . 'references it.' ]; } if ($deleteStmt->rowCount() !== 1) { return [ 'deleted' => false, 'error' => 'The ' . $singular . ' was not deleted.' ]; } /* * WAYFARER MEDIA LINK CLEANUP * * Media is game-level and reusable. Deleting this authored object * removes only its media associations, never the underlying asset. */ $mediaLinkDelete = $pdo->prepare( " DELETE FROM wf_media_links WHERE object_type = ? AND object_id = ? " ); $mediaLinkDelete->execute([ $table, $id ]); return [ 'deleted' => true, 'error' => '' ]; } /* |-------------------------------------------------------------------------- | WAYFARER MEDIA LIBRARY |-------------------------------------------------------------------------- | | Uploaded files are game-level reusable assets stored in wf_media. | wf_media_links associates those assets with authored objects. | */ function getBuilderMediaLibraryDefinition(): array { return [ 'image' => [ 'label' => 'Image', 'directory' => 'images', 'accept' => '.jpg,.jpeg,.png,.gif', 'app_max_bytes' => 10 * 1024 * 1024, 'allowed_mime' => [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif' ], 'help' => 'Optional. JPG, PNG, or GIF.' ], 'audio' => [ 'label' => 'Audio', 'directory' => 'audio', 'accept' => '.mp3,audio/mpeg', 'app_max_bytes' => 30 * 1024 * 1024, 'allowed_mime' => [ 'audio/mpeg' => 'mp3', 'audio/mp3' => 'mp3', 'audio/x-mpeg' => 'mp3', 'audio/mpeg3' => 'mp3', 'audio/x-mpeg-3' => 'mp3' ], 'help' => 'Optional. MP3 only.' ] ]; } function builderIniSizeToBytes($value): int { if ($value === false || $value === null) { return 0; } $value = trim( (string)$value ); if ( $value === '' || $value === '-1' ) { return 0; } $last = strtolower( substr( $value, -1 ) ); $number = (float)$value; if ($last === 'g') { $number *= 1024; $number *= 1024; $number *= 1024; } elseif ($last === 'm') { $number *= 1024; $number *= 1024; } elseif ($last === 'k') { $number *= 1024; } return (int)floor($number); } function getBuilderEffectiveUploadLimit( int $applicationLimit ): int { $limits = [ $applicationLimit ]; $uploadLimit = builderIniSizeToBytes( ini_get( 'upload_max_filesize' ) ); $postLimit = builderIniSizeToBytes( ini_get( 'post_max_size' ) ); if ($uploadLimit > 0) { $limits[] = $uploadLimit; } if ($postLimit > 0) { $limits[] = $postLimit; } return min( $limits ); } function formatBuilderBytes( int $bytes ): string { if ($bytes >= 1024 * 1024 * 1024) { return rtrim( rtrim( number_format( $bytes / 1024 / 1024 / 1024, 1 ), '0' ), '.' ) . ' GB'; } if ($bytes >= 1024 * 1024) { return rtrim( rtrim( number_format( $bytes / 1024 / 1024, 1 ), '0' ), '.' ) . ' MB'; } if ($bytes >= 1024) { return rtrim( rtrim( number_format( $bytes / 1024, 1 ), '0' ), '.' ) . ' KB'; } return $bytes . ' bytes'; } function builderMediaPublicUrl( string $relativePath ): string { return '../' . ltrim( $relativePath, '/' ); } function getBuilderGameMediaAssets( PDO $pdo, int $gameId, string $mediaType ): array { $stmt = $pdo->prepare( " SELECT id, name, original_filename, relative_path, mime_type, file_size FROM wf_media WHERE game_id = ? AND media_type = ? ORDER BY name, original_filename, id " ); $stmt->execute([ $gameId, $mediaType ]); return $stmt->fetchAll(); } function getBuilderMediaLinksForObject( PDO $pdo, int $gameId, string $objectType, int $objectId ): array { if ($objectId <= 0) { return []; } $stmt = $pdo->prepare( " SELECT l.slot, m.id, m.name, m.media_type, m.original_filename, m.relative_path, m.mime_type, m.file_size FROM wf_media_links l INNER JOIN wf_media m ON m.id = l.media_id WHERE l.object_type = ? AND l.object_id = ? AND m.game_id = ? " ); $stmt->execute([ $objectType, $objectId, $gameId ]); $result = []; foreach ($stmt->fetchAll() as $row) { $result[ $row['slot'] ] = $row; } return $result; } function builderMediaUploadErrorMessage( int $error, string $label ): string { if ( $error === UPLOAD_ERR_INI_SIZE || $error === UPLOAD_ERR_FORM_SIZE ) { return $label . ' exceeds the server upload limit.'; } if ($error === UPLOAD_ERR_PARTIAL) { return $label . ' was only partially uploaded. Please try again.'; } if ($error === UPLOAD_ERR_NO_TMP_DIR) { return 'The server has no temporary upload directory.'; } if ($error === UPLOAD_ERR_CANT_WRITE) { return 'The server could not write the uploaded file.'; } if ($error === UPLOAD_ERR_EXTENSION) { return $label . ' was blocked by a server extension.'; } return $label . ' upload failed.'; } function validateBuilderMediaUpload( array $file, string $slot, array $slotDefinition ): array { $label = $slotDefinition['label']; $uploadError = (int)( $file['error'] ?? UPLOAD_ERR_NO_FILE ); if ($uploadError === UPLOAD_ERR_NO_FILE) { return [ 'valid' => false, 'error' => 'Choose a file to upload for ' . strtolower($label) . '.' ]; } if ($uploadError !== UPLOAD_ERR_OK) { return [ 'valid' => false, 'error' => builderMediaUploadErrorMessage( $uploadError, $label ) ]; } $tmpName = (string)( $file['tmp_name'] ?? '' ); if ( $tmpName === '' || !is_uploaded_file( $tmpName ) ) { return [ 'valid' => false, 'error' => 'The uploaded ' . strtolower($label) . ' could not be verified.' ]; } $size = (int)( $file['size'] ?? 0 ); $effectiveLimit = getBuilderEffectiveUploadLimit( (int)$slotDefinition['app_max_bytes'] ); if ( $size <= 0 || $size > $effectiveLimit ) { return [ 'valid' => false, 'error' => $label . ' must be no larger than ' . formatBuilderBytes( $effectiveLimit ) . '.' ]; } $originalName = basename( (string)( $file['name'] ?? '' ) ); $originalExtension = strtolower( pathinfo( $originalName, PATHINFO_EXTENSION ) ); $finfo = finfo_open( FILEINFO_MIME_TYPE ); $mime = $finfo ? finfo_file( $finfo, $tmpName ) : false; if ($finfo) { finfo_close( $finfo ); } $allowedMime = $slotDefinition['allowed_mime']; if ( !$mime || !isset( $allowedMime[$mime] ) ) { return [ 'valid' => false, 'error' => 'Unsupported ' . strtolower($label) . ' file type.' ]; } $normalizedExtension = $allowedMime[$mime]; if ( $slot === 'image' && !in_array( $originalExtension, [ 'jpg', 'jpeg', 'png', 'gif' ], true ) ) { return [ 'valid' => false, 'error' => 'Image filename must end in JPG, JPEG, PNG, or GIF.' ]; } if ( $slot === 'audio' && $originalExtension !== 'mp3' ) { return [ 'valid' => false, 'error' => 'Audio filename must end in MP3.' ]; } if ($slot === 'image') { $imageInfo = @getimagesize( $tmpName ); if ($imageInfo === false) { return [ 'valid' => false, 'error' => 'The uploaded image is not a valid raster image.' ]; } } return [ 'valid' => true, 'error' => '', 'tmp_name' => $tmpName, 'size' => $size, 'mime' => $mime, 'extension' => $normalizedExtension, 'original_filename' => $originalName ]; } function builderMediaDefaultName( string $originalFilename ): string { $name = pathinfo( $originalFilename, PATHINFO_FILENAME ); $name = preg_replace( '/[_-]+/', ' ', $name ); $name = trim( preg_replace( '/\s+/', ' ', $name ) ); if ($name === '') { return 'Untitled Media'; } return $name; } function setBuilderMediaLink( PDO $pdo, int $mediaId, string $objectType, int $objectId, string $slot ): void { $stmt = $pdo->prepare( " INSERT INTO wf_media_links ( media_id, object_type, object_id, slot ) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE media_id = VALUES(media_id) " ); $stmt->execute([ $mediaId, $objectType, $objectId, $slot ]); } function removeBuilderMediaLink( PDO $pdo, string $objectType, int $objectId, string $slot ): void { $stmt = $pdo->prepare( " DELETE FROM wf_media_links WHERE object_type = ? AND object_id = ? AND slot = ? " ); $stmt->execute([ $objectType, $objectId, $slot ]); } function applyBuilderMediaSelections( PDO $pdo, int $gameId, string $objectType, int $objectId, array $post, array $files ): array { if ($objectId <= 0) { return [ 'saved' => false, 'error' => 'Media cannot be attached until the authored object has an ID.' ]; } $definition = getBuilderMediaLibraryDefinition(); $current = getBuilderMediaLinksForObject( $pdo, $gameId, $objectType, $objectId ); $plan = []; /* * First validate every requested operation. Nothing changes yet. */ foreach ($definition as $slot => $slotDefinition) { $defaultAction = isset($current[$slot]) ? 'keep' : 'none'; $action = (string)( $post['media_action'][$slot] ?? $defaultAction ); if ( !in_array( $action, [ 'keep', 'none', 'existing', 'upload' ], true ) ) { return [ 'saved' => false, 'error' => 'Invalid ' . strtolower($slotDefinition['label']) . ' action.' ]; } if ($action === 'keep') { $plan[$slot] = [ 'action' => 'keep' ]; continue; } if ($action === 'none') { $plan[$slot] = [ 'action' => 'none' ]; continue; } if ($action === 'existing') { $mediaId = (int)( $post['media_existing'][$slot] ?? 0 ); if ($mediaId <= 0) { return [ 'saved' => false, 'error' => 'Choose an existing ' . strtolower($slotDefinition['label']) . '.' ]; } $stmt = $pdo->prepare( " SELECT id FROM wf_media WHERE id = ? AND game_id = ? AND media_type = ? LIMIT 1 " ); $stmt->execute([ $mediaId, $gameId, $slot ]); if (!$stmt->fetchColumn()) { return [ 'saved' => false, 'error' => 'The selected existing ' . strtolower($slotDefinition['label']) . ' is not available to this game.' ]; } $plan[$slot] = [ 'action' => 'existing', 'media_id' => $mediaId ]; continue; } $fileKey = 'media_' . $slot; $validation = validateBuilderMediaUpload( $files[$fileKey] ?? [], $slot, $slotDefinition ); if (!$validation['valid']) { return [ 'saved' => false, 'error' => $validation['error'] ]; } $requestedName = trim( (string)( $post['media_name'][$slot] ?? '' ) ); if ($requestedName === '') { $requestedName = builderMediaDefaultName( $validation['original_filename'] ); } if ( mb_strlen( $requestedName ) > 255 ) { return [ 'saved' => false, 'error' => $slotDefinition['label'] . ' library name must be 255 characters or fewer.' ]; } $plan[$slot] = array_merge( [ 'action' => 'upload', 'name' => $requestedName ], $validation ); } $newPhysicalPaths = []; try { $pdo->beginTransaction(); foreach ($plan as $slot => $operation) { if ($operation['action'] === 'keep') { continue; } if ($operation['action'] === 'none') { removeBuilderMediaLink( $pdo, $objectType, $objectId, $slot ); continue; } if ($operation['action'] === 'existing') { setBuilderMediaLink( $pdo, (int)$operation['media_id'], $objectType, $objectId, $slot ); continue; } $slotDefinition = $definition[$slot]; $relativeDirectory = 'uploads/media/game_' . $gameId . '/' . $slotDefinition['directory']; $absoluteDirectory = dirname(__DIR__, 2) . '/' . $relativeDirectory; if ( !is_dir( $absoluteDirectory ) && !mkdir( $absoluteDirectory, 0755, true ) ) { throw new RuntimeException( 'Wayfarer could not create the ' . strtolower($slotDefinition['label']) . ' media directory.' ); } $storedFilename = bin2hex( random_bytes(16) ) . '.' . $operation['extension']; $relativePath = $relativeDirectory . '/' . $storedFilename; $absolutePath = dirname(__DIR__, 2) . '/' . $relativePath; if ( !move_uploaded_file( $operation['tmp_name'], $absolutePath ) ) { throw new RuntimeException( 'Wayfarer could not save the uploaded ' . strtolower($slotDefinition['label']) . '.' ); } $newPhysicalPaths[] = $absolutePath; $insert = $pdo->prepare( " INSERT INTO wf_media ( game_id, media_type, name, original_filename, stored_filename, relative_path, mime_type, file_size, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) " ); $insert->execute([ $gameId, $slot, $operation['name'], $operation['original_filename'], $storedFilename, $relativePath, $operation['mime'], $operation['size'] ]); $mediaId = (int)$pdo->lastInsertId(); setBuilderMediaLink( $pdo, $mediaId, $objectType, $objectId, $slot ); } $pdo->commit(); } catch (Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } foreach ($newPhysicalPaths as $path) { if (is_file($path)) { @unlink($path); } } return [ 'saved' => false, 'error' => $e->getMessage() ]; } return [ 'saved' => true, 'error' => '' ]; } ================================================================================ FILE: includes/builder/game-hud.php ================================================================================

HUD

Choose live values for the Travel and/or Action HUD. Travel items appear directly below the Travel status panel. Action items use the lower HUD strip outside the Action card, so Storylets keep the full card. Leave every Region unchecked to show an item in all Regions.

$component): ?>
HUD Item

>

Regions Leave all unchecked for every Region.

No direct numeric Trait or Item Quantity sources are currently available.

================================================================================ FILE: includes/builder/load-record.php ================================================================================ null, ]; foreach ($relationships as $fieldName => $relationship) { $record[$fieldName] = ''; } foreach ($fields as $fieldName => $field) { $record[$fieldName] = $field['default'] ?? ''; } $configValues = []; foreach ($configFields as $fieldName => $field) { $configValues[$fieldName] = $field['default'] ?? ''; } $traits = []; if (!$id) { return [ 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, ]; } $scopeQuery = buildScopeQuery($definition); $sql = " SELECT `$table`.* FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($sql); $stmt->execute([$id, $gameId]); $existing = $stmt->fetch(); if (!$existing) { die('Record not found.'); } $record = array_merge($record, $existing); if (!empty($existing['config'])) { $decodedConfig = json_decode($existing['config'], true); if (is_array($decodedConfig)) { foreach ($configFields as $fieldName => $field) { if (array_key_exists($fieldName, $decodedConfig)) { $configValues[$fieldName] = $decodedConfig[$fieldName]; } } if ( !empty($definition['traits']) && isset($decodedConfig['traits']) && is_array($decodedConfig['traits']) ) { $traits = $decodedConfig['traits']; } } } return [ 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, ]; } ================================================================================ FILE: includes/builder/location-tick-effects.php ================================================================================ whether the Location is eligible to trigger. | Effects -> one-time state changes when it triggers. | | Conditions and Effects have separate builder tabs. | | Current target model: | - Game Traits | - Current Region Traits | - Navigation Position when the parent Region is linear | | Location Traits are not runtime targets yet because mutable per-Location | Trait state has not been introduced. | | Expected variables: | $pdo | $gameId | $game | $record | $id | */ require_once __DIR__ . '/../gameplay/functions.php'; $locationGameplayConfig = []; if ( !empty( $record['config'] ) ) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $locationGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted one-time Effect rows. * An absent effects collection during POST is canonical empty, which allows * removal of the final Effect. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' ) { $gameplayEffects = isset( $_POST['effects'] ) && is_array( $_POST['effects'] ) ? array_values( $_POST['effects'] ) : []; } else { $gameplayEffects = isset( $locationGameplayConfig['effects'] ) && is_array( $locationGameplayConfig['effects'] ) ? array_values( $locationGameplayConfig['effects'] ) : []; } /* * Failed POSTs retain the author's submitted Tick Effect rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' ) { $gameplayTickEffects = isset( $_POST['tick_effects'] ) && is_array( $_POST['tick_effects'] ) ? array_values( $_POST['tick_effects'] ) : []; } else { $gameplayTickEffects = isset( $locationGameplayConfig['tick_effects'] ) && is_array( $locationGameplayConfig['tick_effects'] ) ? array_values( $locationGameplayConfig['tick_effects'] ) : []; } /* * Game Trait targets. */ $gameConfig = []; if ( !empty( $game['config'] ) ) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * Parent Region context supplies Region Trait targets and determines whether * Navigation Position is valid for this Location. */ $parentRegionTraits = []; $parentRegionNavigationType = ''; $parentRegionId = (int)( $record['region_id'] ?? 0 ); if ($parentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $parentRegionId, $gameId, ]); $parentRegion = $regionStmt->fetch( PDO::FETCH_ASSOC ); if ($parentRegion) { $parentRegionNavigationType = (string)( $parentRegion['navigation_type'] ?? '' ); if ( !empty( $parentRegion['config'] ) ) { $decoded = json_decode( (string)$parentRegion['config'], true ); if ( is_array($decoded) && isset( $decoded['traits'] ) && is_array( $decoded['traits'] ) ) { $parentRegionTraits = $decoded['traits']; } } } } $gameplayScopeTraitTargets = gameplayNumericTraitTargets( $parentRegionTraits, 'region' ); $gameplayScopeKey = 'region'; $gameplayScopeLabel = 'Region'; $gameplayAllowNavigation = strtolower( trim( $parentRegionNavigationType ) ) === 'linear'; $gameplayNavigationType = $parentRegionNavigationType; /* WAYFARER_UNIVERSAL_LOCATION_TARGETS_V56 */ /* WAYFARER_LOCATION_STORYLET_TRANSITIONS_V102 */ $gameplayTargetCatalog = array_replace( gameplayRuntimeStateTargets( $pdo, $gameId ), gameplayStoryletNavigationEffectTargets( $pdo, $gameId ) ); /* * Storylet transitions are Effect-only. Location Conditions must continue to * expose runtime state, not Storylet navigation destinations. */ $gameplayConditionTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); /* * Existing authored transition rows render last. Newly-added rows are already * appended by the shared editor and successful saves reload through this same * normalization. */ $locationOrdinaryEffects = []; $locationStoryletEffects = []; foreach ($gameplayEffects as $locationEffect) { if ( is_array($locationEffect) && (string)($locationEffect['target_scope'] ?? '') === 'storylet' && (string)($locationEffect['target_type'] ?? '') === 'transition' ) { $locationStoryletEffects[] = $locationEffect; } else { $locationOrdinaryEffects[] = $locationEffect; } } $gameplayEffects = array_merge( $locationOrdinaryEffects, $locationStoryletEffects ); /* * WAYFARER_LOCATION_CONDITIONS_V45 * * Empty Conditions mean the Location is always eligible. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' ) { $locationGameplayConditions = $_POST['conditions'] ?? null; } else { $locationGameplayConditions = $locationGameplayConfig[ 'conditions' ] ?? null; } $gameplayEffectDescription = 'Location Effects execute once when this Location is triggered. ' . 'A Storylet → Continue To Effect is always deferred until every other ' . 'Location Effect has finished, then opens the selected root Storylet.'; $gameplayAllowEffectNavigation = false; /* * WAYFARER_LOCATION_EFFECTS_ONLY_V43 * * Locations are event boundaries. Recurring simulation belongs to Game and * Region Tick Effects; Location state changes belong to one-time Effects. */ ?>

Conditions

Optional. The Location triggers only when these conditions are true at the moment the player reaches or crosses its Position. If they are false, travel passes through the Location normally.

================================================================================ FILE: includes/builder/region-hud.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $regionHudGameStmt->execute([ (int)$gameId, ]); $regionHudGameRaw = $regionHudGameStmt->fetchColumn(); $regionHudGameConfig = $regionHudGameRaw ? json_decode( (string)$regionHudGameRaw, true ) : []; if (!is_array($regionHudGameConfig)) { $regionHudGameConfig = []; } $appendHudTrait = static function ( array &$choices, string $scope, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); $name = trim( (string)( $trait['name'] ?? $id ) ); $type = strtolower( trim( (string)( $trait['type'] ?? '' ) ) ); $min = $trait['min'] ?? null; $max = $trait['max'] ?? null; if ( $id === '' || $type !== 'number' || !is_numeric($min) || !is_numeric($max) || (float)$max <= (float)$min ) { return; } $choices[] = [ 'scope' => $scope, 'id' => $id, 'name' => $name !== '' ? $name : $id, 'min' => (float)$min, 'max' => (float)$max, ]; }; foreach ( ( isset($regionHudGameConfig['traits']) && is_array($regionHudGameConfig['traits']) ? $regionHudGameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $appendHudTrait( $regionHudSources, 'game', $trait ); } } foreach ( ( isset($traits) && is_array($traits) ? $traits : [] ) as $trait ) { if (is_array($trait)) { $appendHudTrait( $regionHudSources, 'region', $trait ); } } /* * Preserve a stored choice in the editor even if its Trait was later changed * so it is no longer a valid bounded numeric progress-bar source. Save-time * validation will require the author to repair it. */ $regionHudSourceKeys = []; foreach ($regionHudSources as $source) { $regionHudSourceKeys[ $source['scope'] . '|' . $source['id'] ] = true; } foreach ($regionHudComponents as $component) { if (!is_array($component)) { continue; } $storedScope = (string)( $component['source_scope'] ?? '' ); $storedId = (string)( $component['source_id'] ?? '' ); $storedKey = $storedScope . '|' . $storedId; if ( $storedScope !== '' && $storedId !== '' && empty( $regionHudSourceKeys[ $storedKey ] ) ) { $regionHudSources[] = [ 'scope' => $storedScope, 'id' => $storedId, 'name' => '[Unavailable] ' . $storedId, 'min' => 0, 'max' => 1, ]; $regionHudSourceKeys[ $storedKey ] = true; } } ?>

Player HUD

Choose the persistent information shown at the bottom of the player screen while this Region is active. The same HUD remains visible in both Action and Travel modes; full-screen Location and Storylet presentations cover it.

$component ): ?>
Progress Bar

No bounded numeric Traits are currently available. A Progress Bar requires a Number Trait with both a Minimum and Maximum.

================================================================================ FILE: includes/builder/region-travel.php ================================================================================

Linear Travel

Configure the Region's normal movement rate independently from the player's current direction. Direction is runtime journey state; this section defines how far one normal tick moves along the line.

> Save this Region with Navigation Type set to Linear before enabling dedicated travel.

> Movement


The normal distance moved each tick before future conditional travel modifiers are applied. The player's current direction determines whether this distance increases or decreases Position.


When enabled, Action Mode offers Turn Around. Turning around does not consume a separate tick; the next travel tick moves in the opposite direction.

Compatibility: until dedicated Linear Travel is enabled, this Region continues using its existing Navigation Position Tick Effect. Once enabled, Region Tick Effects that directly target Navigation Position are ignored so movement is not applied twice. All other Region Tick Effects continue normally.

================================================================================ FILE: includes/builder/relationship-field.php ================================================================================

================================================================================ FILE: includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ /* WAYFARER_LOCATION_STORYLET_TRANSITIONS_V102 */ $allowedTargets = array_replace( gameplayRuntimeStateTargets( $pdo, $gameId ), gameplayStoryletNavigationEffectTargets( $pdo, $gameId ) ); $result = gameplayBuildEffectsFromPost( $allowedTargets, $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); if (($result['error'] ?? '') !== '') { return $result; } $transitionCount = 0; foreach ($result['effects'] ?? [] as $effect) { if ( is_array($effect) && (string)($effect['target_scope'] ?? '') === 'storylet' && (string)($effect['target_type'] ?? '') === 'transition' ) { $transitionCount++; } } if ($transitionCount > 1) { return [ 'effects' => [], 'error' => 'A Location may have only one Storylet → Continue To Effect.', ]; } return $result; } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects, array $storyletBindings = [], int $currentStoryletId = 0 ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $allowedTargets = array_replace( gameplayRuntimeStateTargets( $pdo, $gameId ), gameplayStoryletBindingEffectTargets( $pdo, $gameId, $storyletBindings ), gameplayStoryletNavigationEffectTargets( $pdo, $gameId, $currentStoryletId ) ); $result = gameplayBuildEffectsFromPost( $allowedTargets, $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); if (($result['error'] ?? '') !== '') { return $result; } $transitionCount = 0; foreach ( $result['effects'] ?? [] as $effect ) { if ( is_array($effect) && (string)( $effect['target_scope'] ?? '' ) === 'storylet' && (string)( $effect['target_type'] ?? '' ) === 'transition' ) { $transitionCount++; } } if ($transitionCount > 1) { return [ 'effects' => [], 'error' => 'A Storylet may have only one Storylet → Continue To Effect.', ]; } return $result; } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } /* WAYFARER_SYSTEM_TRAIT_RANDOM_INITIAL_SAVE_V93 */ function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /* * WAYFARER_LINEAR_TRAVEL_SAVE_V72 * * Dedicated Linear Travel is deliberately opt-in. A null travel result means * the Region should continue using the legacy Position Tick Effect path. */ function buildRegionTravelFromPost( array $record, array $postedTravel ): array { $navigationType = strtolower( trim( (string)( $record['navigation_type'] ?? '' ) ) ); if ($navigationType !== 'linear') { return [ 'travel' => null, 'error' => '', ]; } if (empty($postedTravel['enabled'])) { return [ 'travel' => null, 'error' => '', ]; } $baseDistance = $postedTravel['base_distance'] ?? ''; if ( !is_numeric($baseDistance) || (float)$baseDistance <= 0 ) { return [ 'travel' => null, 'error' => 'Base Distance Per Tick must be greater than zero.', ]; } $baseDistance = (float)$baseDistance; if (floor($baseDistance) === $baseDistance) { $baseDistance = (int)$baseDistance; } return [ 'travel' => [ 'enabled' => true, 'base_distance' => $baseDistance, 'allow_turnaround' => !empty( $postedTravel[ 'allow_turnaround' ] ), ], 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Storylet duplication | WAYFARER_STORYLET_DUPLICATE_V83 |-------------------------------------------------------------------------- | | Duplicate the currently saved Storylet as a Draft. The duplicate copies | the selected Storylet record and its media links, but intentionally does | not recursively copy child Branch Storylets. | */ function duplicateStoryletRecord( PDO $pdo, int $gameId, int $storyletId ): array { if ($gameId <= 0 || $storyletId <= 0) { return [ 'duplicated' => false, 'id' => 0, 'error' => 'A valid Storylet is required to duplicate.', ]; } try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, uuid, game_id, name, storylet_key, description, conditions, effects, max_occurrences, trigger_type, status, config, parent_storylet_id, choice_label, choice_sort_order, branch_label, branch_sort_order FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $storyletId, $gameId, ]); $source = $stmt->fetch(PDO::FETCH_ASSOC); if (!$source) { throw new RuntimeException( 'The Storylet to duplicate could not be found.' ); } $sourceName = trim( (string)( $source['name'] ?? 'Storylet' ) ); if ($sourceName === '') { $sourceName = 'Storylet'; } $baseName = preg_replace( '/\s+-\s+COPY(?:\s+\d+)?$/i', '', $sourceName ); if (!is_string($baseName) || trim($baseName) === '') { $baseName = $sourceName; } $baseName = trim($baseName); /* Leave room for " - COPY 9999" within the 200-character Name. */ $baseName = function_exists('mb_substr') ? mb_substr($baseName, 0, 185, 'UTF-8') : substr($baseName, 0, 185); $copyName = $baseName . ' - COPY'; $copyNumber = 2; $nameCheck = $pdo->prepare( " SELECT 1 FROM wf_storylets WHERE game_id = ? AND name = ? LIMIT 1 " ); while (true) { $nameCheck->execute([ $gameId, $copyName, ]); if (!$nameCheck->fetchColumn()) { break; } $copyName = $baseName . ' - COPY ' . $copyNumber; $copyNumber++; if ($copyNumber > 9999) { throw new RuntimeException( 'Could not generate a unique Storylet copy name.' ); } } $sourceKey = trim( (string)( $source['storylet_key'] ?? '' ) ); if ($sourceKey === '') { $sourceKey = 'storylet-' . $storyletId; } $copyKey = substr( $sourceKey, 0, 80 ) . '-copy-' . bin2hex( random_bytes(6) ); $copyUuid = bin2hex( random_bytes(16) ); $parentId = (int)( $source['parent_storylet_id'] ?? 0 ); $branchSortOrder = (int)( $source['branch_sort_order'] ?? 0 ); $choiceSortOrder = (int)( $source['choice_sort_order'] ?? 0 ); $branchLabel = (string)( $source['branch_label'] ?? '' ); $choiceLabel = (string)( $source['choice_label'] ?? '' ); if ($parentId > 0) { $orderStmt = $pdo->prepare( " SELECT COALESCE(MAX(branch_sort_order), 0) FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? " ); $orderStmt->execute([ $gameId, $parentId, ]); $branchSortOrder = (int)$orderStmt->fetchColumn() + 10; $choiceSortOrder = $branchSortOrder; if (trim($branchLabel) !== '') { $branchLabel = rtrim($branchLabel) . ' - COPY'; } if (trim($choiceLabel) !== '') { $choiceLabel = rtrim($choiceLabel) . ' - COPY'; } } $insert = $pdo->prepare( " INSERT INTO wf_storylets ( uuid, game_id, name, storylet_key, description, conditions, effects, max_occurrences, trigger_type, status, config, parent_storylet_id, choice_label, choice_sort_order, branch_label, branch_sort_order ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?) " ); $insert->execute([ $copyUuid, $gameId, $copyName, $copyKey, $source['description'] ?? null, $source['conditions'] ?? null, $source['effects'] ?? null, $source['max_occurrences'] ?? null, $source['trigger_type'] ?? 'manual', $source['config'] ?? null, $parentId > 0 ? $parentId : null, $choiceLabel !== '' ? $choiceLabel : null, $choiceSortOrder, $branchLabel, $branchSortOrder, ]); $copyId = (int)$pdo->lastInsertId(); if ($copyId <= 0) { throw new RuntimeException( 'The Storylet copy could not be created.' ); } /* Reuse the same game-level media assets; do not duplicate files. */ $media = $pdo->prepare( " INSERT INTO wf_media_links ( media_id, object_type, object_id, slot ) SELECT media_id, object_type, ?, slot FROM wf_media_links WHERE object_type = 'wf_storylets' AND object_id = ? " ); $media->execute([ $copyId, $storyletId, ]); $pdo->commit(); return [ 'duplicated' => true, 'id' => $copyId, 'error' => '', ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } return [ 'duplicated' => false, 'id' => 0, 'error' => $exception->getMessage(), ]; } } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } $traitType = $traitDefinition['type'] ?? 'text'; $initialMode = 'fixed'; $randomInitialMin = null; $randomInitialMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_random_initial']) ) { $initialMode = strtolower( trim( (string)( $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['initial_mode'] ?? 'fixed' ) ) ); if ( !in_array( $initialMode, ['fixed', 'random'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ': invalid initial value mode.'; break; } if ($initialMode === 'random') { $postedInitialMin = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['initial_min'] ?? ''; $postedInitialMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['initial_max'] ?? ''; if (is_string($postedInitialMin)) { $postedInitialMin = trim($postedInitialMin); } if (is_string($postedInitialMax)) { $postedInitialMax = trim($postedInitialMax); } if ( $postedInitialMin === '' || !is_numeric($postedInitialMin) ) { $error = ($traitDefinition['label'] ?? $traitName) . ': random initial minimum must be a number.'; break; } if ( $postedInitialMax === '' || !is_numeric($postedInitialMax) ) { $error = ($traitDefinition['label'] ?? $traitName) . ': random initial maximum must be a number.'; break; } $randomInitialMin = (float)$postedInitialMin; $randomInitialMax = (float)$postedInitialMax; if ($randomInitialMin > $randomInitialMax) { $error = ($traitDefinition['label'] ?? $traitName) . ': random initial minimum cannot be greater than maximum.'; break; } if ( isset($traitDefinition['min']) && $randomInitialMin < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ': random initial minimum must be at least ' . $traitDefinition['min'] . '.'; break; } if ( isset($traitDefinition['max']) && $randomInitialMax > (float)$traitDefinition['max'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ': random initial maximum cannot exceed ' . $traitDefinition['max'] . '.'; break; } } } if ( !empty($traitDefinition['required']) && $value === '' && $initialMode !== 'random' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } if ( $traitType === 'number' && $initialMode !== 'random' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $initialMode !== 'random' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } $startingMaximum = $initialMode === 'random' ? $randomInitialMax : (float)$value; if ( $startingMaximum !== null && (float)$startingMaximum > (float)$postedMax ) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTrait = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $initialMode === 'random' ? 0 : $value, $authorMax ); if ( $traitType === 'number' && !empty($traitDefinition['allow_random_initial']) ) { $systemTrait['initial_mode'] = $initialMode; if ($initialMode === 'random') { $systemTrait['initial_min'] = $randomInitialMin; $systemTrait['initial_max'] = $randomInitialMax; } } $systemTraits[] = $systemTrait; } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_LINEAR_TRAVEL_SAVE_V72 */ $regionTravel = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionTravelResult = buildRegionTravelFromPost( $record, isset($post['travel']) && is_array($post['travel']) ? $post['travel'] : [] ); $regionTravel = $regionTravelResult['travel'] ?? null; if ( ($regionTravelResult['error'] ?? '') !== '' ) { $error = (string)$regionTravelResult['error']; } } /* WAYFARER_GAME_HUD_MOVED_SAVE_V94 * Region HUD authoring moved to Game Settings. Existing config.hud data is * deliberately left untouched here so V94 can read it as legacy fallback. */ /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STATUS_LIFECYCLE_SAVE_V88 */ /* WAYFARER_STATUS_OWNER_SAVE_V92 */ $statusLifecycle = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_statuses' ) { $statusLifecycleResult = gameplayBuildStatusLifecycleFromPost( $pdo, $gameId, $post['status_lifecycle'] ?? [], (string)($configValues['applies_to'] ?? 'character') ); $statusLifecycle = $statusLifecycleResult['lifecycle'] ?? null; $record['_status_lifecycle_editor'] = is_array( $statusLifecycleResult['editor'] ?? null ) ? $statusLifecycleResult['editor'] : []; if ( ($statusLifecycleResult['error'] ?? '') !== '' ) { $error = (string)$statusLifecycleResult['error']; } } /* WAYFARER_STORYLET_PARSE_V56 */ /* WAYFARER_STORYLET_BINDINGS_V79 */ $storyletBindings = []; $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $bindingResult = gameplayBuildStoryletBindingsFromPost( isset($post['bindings']) && is_array($post['bindings']) ? array_values($post['bindings']) : [] ); $storyletBindings = $bindingResult['bindings']; if ($bindingResult['error'] !== '') { $error = $bindingResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [], $storyletBindings, $postedId ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial_mode' => (string)( $systemTrait['initial_mode'] ?? 'fixed' ), 'initial' => $systemTrait['initial'] ?? 0, 'initial_min' => $systemTrait['initial_min'] ?? null, 'initial_max' => $systemTrait['initial_max'] ?? null, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_LINEAR_TRAVEL_SAVE_V72 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTravel !== null) { $configToSave['travel'] = $regionTravel; } else { unset( $configToSave['travel'] ); } } /* WAYFARER_GAME_HUD_MOVED_CONFIG_V94 * Preserve any legacy Region config.hud key already present in * $configToSave. New HUD definitions are saved at Game level. */ /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STATUS_LIFECYCLE_CONFIG_V88 */ if ( ($definition['table'] ?? '') === 'wf_statuses' ) { if (is_array($statusLifecycle)) { $configToSave['lifecycle'] = $statusLifecycle; } else { unset($configToSave['lifecycle']); } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { /* WAYFARER_STORYLET_BINDINGS_V79 */ if ($storyletBindings) { $configToSave['bindings'] = array_values($storyletBindings); } else { unset($configToSave['bindings']); } if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } /* * WAYFARER_STORYLET_INTERNAL_IDS_V91 * * uuid and storylet_key are structural Storylet identifiers, not * author-facing Builder fields. The generic INSERT therefore has to add * them explicitly for a new wf_storylets row. */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { $storyletUuid = bin2hex( random_bytes(16) ); $storyletKey = 'storylet-' . substr( $storyletUuid, 0, 24 ); $columns[] = '`uuid`'; $placeholders[] = '?'; $values[] = $storyletUuid; $columns[] = '`storylet_key`'; $placeholders[] = '?'; $values[] = $storyletKey; /* * Keep returned editor state coherent even though these values are * intentionally not displayed as authored fields. */ $record['uuid'] = $storyletUuid; $record['storylet_key'] = $storyletKey; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; /* * WAYFARER_STORYLET_UUID_SAVE_V90 * * Storylet UUID is an internal persistent identifier, not an authored * form value. Older/new-record builder state can therefore carry an * empty string here. Never send that empty value to the UNIQUE * wf_storylets.uuid column; create the identifier at insert time. * * Existing Storylet edits keep their stored UUID unchanged because * this branch runs only in the new-record INSERT path. */ if ( ($definition['table'] ?? '') === 'wf_storylets' && $fieldName === 'uuid' && trim((string)$value) === '' ) { $value = bin2hex( random_bytes(16) ); $record[$fieldName] = $value; } if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: includes/builder/status-lifecycle.php ================================================================================ Select root Storylet...'; foreach ($storylets as $storylet) { $id = (int)($storylet['id'] ?? 0); if ($id <= 0) { continue; } $name = trim((string)($storylet['name'] ?? ('Storylet ' . $id))); $status = strtolower(trim((string)($storylet['status'] ?? 'draft'))); $trigger = strtolower(trim((string)($storylet['trigger_type'] ?? 'manual'))); $suffix = []; if ($status !== 'active') { $suffix[] = ucfirst($status); } if ($trigger === 'event') { $suffix[] = 'Event'; } $label = $name; if ($suffix) { $label .= ' [' . implode(', ', $suffix) . ']'; } echo ''; } }; $statusLifecycleRenderResultOptions = static function ( array $stages, string $selected ): void { echo ''; echo ''; foreach ($stages as $stage) { if (!is_array($stage)) { continue; } $key = trim((string)($stage['key'] ?? '')); $name = trim((string)($stage['name'] ?? $key)); if ($key === '') { continue; } $value = 'stage:' . $key; echo ''; } }; /* WAYFARER_STATUS_OWNER_EFFECTS_EDITOR_V92 */ $statusLifecycleAppliesTo = strtolower( trim( (string)( $configValues['applies_to'] ?? 'character' ) ) ); if (!in_array($statusLifecycleAppliesTo, ['character', 'item', 'both'], true)) { $statusLifecycleAppliesTo = 'character'; } $statusLifecycleTraitCatalog = gameplayStatusLifecycleOwnerTraitCatalog( $pdo, (int)$gameId, $statusLifecycleAppliesTo ); $statusLifecycleRenderTraitOptions = static function ( array $catalog, string $selected = '' ): void { echo ''; foreach ($catalog as $targetId => $definition) { if (!is_array($definition)) { continue; } $name = trim((string)($definition['name'] ?? '')); $dataType = strtolower(trim((string)($definition['data_type'] ?? 'number'))); if ($name === '') { continue; } echo ''; } }; $statusLifecycleRenderEffectRow = static function ( array $effect, string $namePrefix, bool $recurring, array $traitCatalog, callable $renderTraitOptions ): void { $effectKey = trim((string)($effect['key'] ?? '')); $targetId = trim((string)($effect['target_id'] ?? '')); $operation = strtolower(trim((string)($effect['operation'] ?? 'set'))); $valueMode = strtolower(trim((string)($effect['value_mode'] ?? 'fixed'))); $value = $effect['value'] ?? 0; $valueMin = $effect['value_min'] ?? 0; $valueMax = $effect['value_max'] ?? 0; $interval = max(1, (int)($effect['interval_ticks'] ?? 1)); $dataType = isset($traitCatalog[$targetId]) ? (string)($traitCatalog[$targetId]['data_type'] ?? 'number') : strtolower(trim((string)($effect['data_type'] ?? 'number'))); ?>
>

Status Progression

Manual Statuses stay active until an Effect changes them. Timed Stages advance independently for every affected character. The first Stage is entered when the Status becomes active.

>

Stages

Each Stage rolls its duration once on entry. When its timer expires, exactly one Outcome is chosen from the authored percentages.
$stage): ?>
Stage
On Enter Effects Applied once to the Status Owner when this Stage begins.
$effect): ?>
While in Stage Effects Applied to the Status Owner every authored number of gameplay ticks. The first interval starts when the Stage starts.
$effect): ?>
Outcomes Chances for this Stage must total exactly 100%.
$outcome): ?>
Outcome Effects Applied once to the Status Owner when this Stage ends, before the Result transition and Storylet presentation.
$effect): ?>

When an Outcome opens a Storylet, Wayfarer supplies the affected character as the reserved subject binding. Use {{subject}} or {{subject.name}} in Storylet text. If the Storylet defines a normal Binding with Key subject, Status-triggered entry reuses the affected character instead of rolling a new one.

================================================================================ FILE: includes/builder/storylet-gameplay.php ================================================================================ (string)(end($target['hierarchy'])['label'] ?? $target['target_id']), 'token' => '{{location.' . (string)$target['target_id'] . '}}', ]; } echo ''; $storyletEffectTargets = array_replace( $storyletConditionTargets, gameplayStoryletBindingEffectTargets( $pdo, $gameId, $storyletBindingDefinitions ), gameplayStoryletNavigationEffectTargets( $pdo, $gameId, (int)($id ?? 0) ) ); $gameplayTargetCatalog = $storyletEffectTargets; ?>

Bindings

A Binding selects one runtime subject when this Storylet begins and keeps that same selection for the entire Storylet occurrence. Effects and narrative tokens can then refer to the selected subject consistently.

Binding Name is the author-facing name shown elsewhere in the builder. Key is the internal identifier used by narrative tokens such as {{victim.name}}; Wayfarer normalizes it to lowercase letters, numbers, and underscores. Selection Rule decides what is selected. Random Party Member chooses from both named Player Characters and recruited Character Entities currently in the party; if an Effect uses a Trait such as Health, only party members that expose that Trait are eligible. Random Possessed Item chooses from Items whose current Quantity is greater than 0.

$bindingRow ): ?>
Narrative Token:

Conditions

Conditions determine whether this Storylet is eligible. A root Manual Storylet can be offered only while these are true. Automatic and Chance Storylets can fire only while eligible. For a child Storylet, these same Conditions determine whether its Branch is available to the player.

Effects

These Effects run once when the player enters this Storylet. Each Effect may use ordered conditional results and an optional Otherwise result. Targets come from Wayfarer's universal runtime-state catalog, so the Storylet may inspect or change Game, Player, Region, Location, Item, and Character Trait state. Definition metadata such as min/max is not a target.

Branches

A Branch is another Storylet whose Parent Storylet is this Storylet. The child Storylet's Conditions decide whether the Branch is available; entering that child runs its Effects. Children may have their own Branches, so this structure can nest to any practical depth without a separate branch-object schema.

Save this Storylet first. After it has an ID, you can add child Branch Storylets here.

prepare( " SELECT id, name, branch_label, branch_sort_order, trigger_type, status FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? ORDER BY branch_sort_order, name, id " ); $branchStmt->execute([ $gameId, (int)$id, ]); $storyletBranches = $branchStmt->fetchAll( PDO::FETCH_ASSOC ); ?>

+ New Branch Storylet

No child Branches have been created yet.

Order Branch Storylet Trigger Status
================================================================================ FILE: includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: includes/gameplay/condition-editor.php ================================================================================ Select target...'; $grouped = []; foreach ($targets as $key => $target) { if (!is_array($target)) { continue; } /* * WAYFARER_LOCATION_CONDITION_TARGETS_V75 * * Keep a hidden legacy target visible only when an existing * Condition already uses it. */ if ( !empty($target['condition_hidden']) && $key !== $selected ) { continue; } $label = (string)( $target['label'] ?? $key ); $group = (string)( $target['group'] ?? 'Targets' ); if (!isset($grouped[$group])) { $grouped[$group] = []; } $helper = trim( (string)( $target['helper'] ?? '' ) ); $grouped[$group][$key] = $helper !== '' ? $label . ' — ' . $helper : $label; } foreach ($grouped as $group => $options) { echo ''; foreach ($options as $value => $label) { echo ''; } echo ''; } } function wayfarerConditionEditorRenderNode( $node, string $name, array $targets, bool $isRoot = false ): void { if ( !is_array( $node ) || ( ($node['type'] ?? '') !== 'group' && ($node['type'] ?? '') !== 'condition' ) ) { $node = wayfarerConditionEmptyGroup( 'all' ); } $type = (string)( $node['type'] ?? 'group' ); if ($type === 'condition') { $targetKey = (string)( $node['scope'] ?? '' ) . ':' . (string)( $node['target_type'] ?? '' ) . ':' . (string)( $node['target_id'] ?? '' ); $operator = (string)( $node['operator'] ?? 'equals' ); $value = $node['value'] ?? ''; /* WAYFARER_DATATYPE_CONDITION_EDITOR_V58 */ $targetDefinition = isset($targets[$targetKey]) && is_array($targets[$targetKey]) ? $targets[$targetKey] : []; $allowedOperators = isset($targetDefinition['condition_operators']) && is_array($targetDefinition['condition_operators']) ? array_values($targetDefinition['condition_operators']) : wayfarerConditionOperators(); $valueOptions = isset($targetDefinition['value_options']) && is_array($targetDefinition['value_options']) ? $targetDefinition['value_options'] : []; $dataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($dataType === 'binary') { $dataType = 'boolean'; } ?>
of the following
$child ): ?> '; wayfarerConditionEditorRenderNode( $child, $name . '[children][' . $childIndex . ']', $targets, false ); echo '
'; ?>
'group', 'mode' => 'all', 'children' => [ $conditionEditorNode, ], ]; } ?>
================================================================================ FILE: includes/gameplay/conditions.php ================================================================================ 'group', 'mode' => $mode, 'children' => [], ]; } function wayfarerConditionNormalizeScalar( $value ) { if (is_bool($value)) { return $value; } if ( is_int($value) || is_float($value) ) { return $value; } if ($value === null) { return null; } $stringValue = trim( (string)$value ); $lower = strtolower( $stringValue ); if ($lower === 'true') { return true; } if ($lower === 'false') { return false; } if ($lower === 'null') { return null; } if ( $stringValue !== '' && is_numeric($stringValue) ) { return strpos( $stringValue, '.' ) !== false ? (float)$stringValue : (int)$stringValue; } return $stringValue; } function wayfarerConditionCompare( $actual, string $operator, $expected ): bool { if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return false; } $actual = wayfarerConditionNormalizeScalar( $actual ); $expected = wayfarerConditionNormalizeScalar( $expected ); /* * Numeric comparisons should be numeric when both sides are numeric. * Otherwise equality is strict after scalar normalization and ordering * comparisons use lexical string comparison. */ $bothNumeric = is_numeric($actual) && is_numeric($expected); if ($bothNumeric) { $left = (float)$actual; $right = (float)$expected; } else { $left = $actual; $right = $expected; } switch ($operator) { case 'equals': return $left === $right; case 'not_equals': return $left !== $right; case 'greater_than': return $bothNumeric ? $left > $right : strcmp( (string)$left, (string)$right ) > 0; case 'greater_than_or_equal': return $bothNumeric ? $left >= $right : strcmp( (string)$left, (string)$right ) >= 0; case 'less_than': return $bothNumeric ? $left < $right : strcmp( (string)$left, (string)$right ) < 0; case 'less_than_or_equal': return $bothNumeric ? $left <= $right : strcmp( (string)$left, (string)$right ) <= 0; } return false; } function wayfarerConditionValidateNode( $node, int $depth = 0 ): array { if ($depth > 20) { return [ 'valid' => false, 'error' => 'Condition nesting is too deep.', 'node' => null, ]; } /* * No condition tree means "always true". */ if ( $node === null || $node === [] ) { return [ 'valid' => true, 'error' => '', 'node' => null, ]; } if (!is_array($node)) { return [ 'valid' => false, 'error' => 'Condition data must be an object.', 'node' => null, ]; } $type = trim( (string)( $node['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $node['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'valid' => false, 'error' => 'Condition groups must use ALL or ANY.', 'node' => null, ]; } $children = $node['children'] ?? []; if (!is_array($children)) { return [ 'valid' => false, 'error' => 'Condition group children must be an array.', 'node' => null, ]; } $normalizedChildren = []; foreach ($children as $child) { $result = wayfarerConditionValidateNode( $child, $depth + 1 ); if (!$result['valid']) { return $result; } if ($result['node'] !== null) { $normalizedChildren[] = $result['node']; } } return [ 'valid' => true, 'error' => '', 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $normalizedChildren, ], ]; } if ($type === 'condition') { $scope = trim( (string)( $node['scope'] ?? '' ) ); $targetType = trim( (string)( $node['target_type'] ?? '' ) ); $targetId = trim( (string)( $node['target_id'] ?? '' ) ); $operator = trim( (string)( $node['operator'] ?? '' ) ); if ( $scope === '' || $targetType === '' || $targetId === '' ) { return [ 'valid' => false, 'error' => 'Each condition must identify a scope and target.', 'node' => null, ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'valid' => false, 'error' => 'Invalid condition operator.', 'node' => null, ]; } if (!array_key_exists('value', $node)) { return [ 'valid' => false, 'error' => 'Each condition must include a comparison value.', 'node' => null, ]; } return [ 'valid' => true, 'error' => '', 'node' => [ 'type' => 'condition', 'scope' => $scope, 'target_type' => $targetType, 'target_id' => $targetId, 'operator' => $operator, 'value' => $node['value'], ], ]; } return [ 'valid' => false, 'error' => 'Condition nodes must be a group or condition.', 'node' => null, ]; } function wayfarerConditionEvaluate( $node, callable $resolver, int $depth = 0 ): bool { /* * Absence of conditions means unconditional / always true. */ if ( $node === null || $node === [] ) { return true; } if ( $depth > 20 || !is_array($node) ) { return false; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { $scope = (string)( $node['scope'] ?? '' ); $targetType = (string)( $node['target_type'] ?? '' ); $targetId = (string)( $node['target_id'] ?? '' ); $operator = (string)( $node['operator'] ?? '' ); if ( $scope === '' || $targetType === '' || $targetId === '' || !array_key_exists( 'value', $node ) ) { return false; } /* * Resolver contract: * * return [ * 'found' => true, * 'value' => * ]; * * Missing targets fail closed rather than silently passing. */ $resolved = $resolver( $scope, $targetType, $targetId ); if ( !is_array($resolved) || empty($resolved['found']) ) { return false; } /* WAYFARER_LIVE_LOCATION_ANCHORS_V76 */ if ( array_key_exists( 'comparison_scope_match', $resolved ) && !$resolved['comparison_scope_match'] ) { return $operator === 'not_equals'; } return wayfarerConditionCompare( $resolved['value'] ?? null, $operator, $node['value'] ); } if ($type === 'group') { $mode = (string)( $node['mode'] ?? 'all' ); $children = $node['children'] ?? []; if (!is_array($children)) { return false; } /* * Empty groups are true. This keeps an empty root group equivalent * to "no conditions" and makes progressive authoring predictable. */ if ($children === []) { return true; } if ($mode === 'all') { foreach ($children as $child) { if ( !wayfarerConditionEvaluate( $child, $resolver, $depth + 1 ) ) { return false; } } return true; } if ($mode === 'any') { foreach ($children as $child) { if ( wayfarerConditionEvaluate( $child, $resolver, $depth + 1 ) ) { return true; } } return false; } return false; } return false; } function wayfarerConditionsPass( $conditions, callable $resolver ): bool { $validation = wayfarerConditionValidateNode( $conditions ); if (!$validation['valid']) { return false; } return wayfarerConditionEvaluate( $validation['node'], $resolver ); } ================================================================================ FILE: includes/gameplay/effects-editor.php ================================================================================ operation/value * OTHERWISE -> operation/value * * First matching result wins. Each result may use a Fixed value or roll a * Random Min-Max value once when the Effect executes. */ $gameplayEffects = isset( $gameplayEffects ) && is_array( $gameplayEffects ) ? array_values( $gameplayEffects ) : []; $gameplayGameTraitTargets = $gameplayGameTraitTargets ?? []; $gameplayScopeTraitTargets = $gameplayScopeTraitTargets ?? []; $gameplayScopeKey = (string)( $gameplayScopeKey ?? '' ); $gameplayScopeLabel = (string)( $gameplayScopeLabel ?? '' ); $gameplayAllowEffectNavigation = !empty( $gameplayAllowEffectNavigation ); $gameplayEffectDescription = (string)( $gameplayEffectDescription ?? 'These Effects run once when this object is triggered.' ); $gameplayConditionTargets = isset( $gameplayConditionTargets ) && is_array( $gameplayConditionTargets ) ? $gameplayConditionTargets : []; /* WAYFARER_LIVE_LOCATION_ANCHORS_V76 */ if ( isset($pdo, $gameId) && $pdo instanceof PDO && function_exists( 'gameplayNavigationConditionTargets' ) ) { $gameplayConditionTargets = array_replace( $gameplayConditionTargets, gameplayNavigationConditionTargets( $pdo, (int)$gameId ), function_exists( 'gameplayTimeConditionTargets' ) ? gameplayTimeConditionTargets( $pdo, (int)$gameId ) : [] ); } /* WAYFARER_UNIVERSAL_EFFECT_TARGET_EDITOR_V56 */ $gameplayTargetCatalog = isset($gameplayTargetCatalog) && is_array($gameplayTargetCatalog) ? $gameplayTargetCatalog : []; $renderEffectTargetOptions = function ( array $effect = [] ) use ( $gameplayAllowEffectNavigation, $gameplayGameTraitTargets, $gameplayScopeKey, $gameplayScopeLabel, $gameplayScopeTraitTargets, $gameplayTargetCatalog ): void { echo ''; if ($gameplayTargetCatalog) { $selectedKey = (string)( $effect['target_scope'] ?? '' ) . ':' . (string)( $effect['target_type'] ?? '' ) . ':' . (string)( $effect['target_id'] ?? '' ); $grouped = []; foreach ( $gameplayTargetCatalog as $value => $target ) { if (!is_array($target)) { continue; } if ( !empty($target['effect_hidden']) && $value !== $selectedKey ) { continue; } $group = (string)( $target['group'] ?? 'Targets' ); if (!isset($grouped[$group])) { $grouped[$group] = []; } $grouped[$group][$value] = $target; } foreach ($grouped as $group => $options) { echo ''; foreach ($options as $value => $target) { echo ''; } echo ''; } return; } if ($gameplayAllowEffectNavigation) { echo ''; echo ''; echo ''; } if ($gameplayGameTraitTargets) { echo ''; foreach ( $gameplayGameTraitTargets as $targetTrait ) { echo ''; } echo ''; } if ( $gameplayScopeKey !== '' && $gameplayScopeTraitTargets ) { echo ''; foreach ( $gameplayScopeTraitTargets as $targetTrait ) { echo ''; } echo ''; } }; $renderEffectResultFields = function ( array $branch, string $name ): void { $operation = gameplayEffectBranchOperation( $branch ); $valueMode = gameplayEffectBranchValueMode( $branch ); $value = gameplayEffectBranchValue( $branch ); $minimum = gameplayEffectBranchMin( $branch ); $maximum = gameplayEffectBranchMax( $branch ); $noChange = $operation === 'none'; ?>
>

Effects

Each Effect controls one target. Results are checked from top to bottom; the first matching result wins. Random values are rolled once when the Effect fires.

$effect ): ?>
$branch ): ?>
THEN

================================================================================ FILE: includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', /* WAYFARER_HUD_TARGET_BOUNDS_V94 */ 'min' => $dataType === 'number' && isset($trait['min']) && is_numeric($trait['min']) ? (float)$trait['min'] : null, 'max' => $dataType === 'number' && isset($trait['max']) && is_numeric($trait['max']) ? (float)$trait['max'] : null, 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* |-------------------------------------------------------------------------- | Party-member Trait catalog | WAYFARER_PARTY_TARGETS_V85 |-------------------------------------------------------------------------- | | Player Traits and authored Character Entity Traits may represent the same | logical per-person property (for example, Health). Party-level authoring | addresses that property by normalized name so one Effect can operate across | the current party without exposing runtime player-slot numbers. | */ function gameplayPartyTraitNormalizeName( string $value ): string { $value = trim( preg_replace( '/\\s+/u', ' ', $value ) ?? $value ); if (function_exists('mb_strtolower')) { return mb_strtolower( $value, 'UTF-8' ); } return strtolower($value); } function gameplayPartyTraitTargetId( string $traitName ): string { return 'party_name_' . bin2hex( gameplayPartyTraitNormalizeName( $traitName ) ); } function gameplayPartyTraitCatalog( PDO $pdo, int $gameId, ?array $playerTraitsOverride = null ): array { if ($gameId <= 0) { return []; } $catalog = []; $append = static function ( array &$catalog, array $trait ): void { if ( !is_array($trait) || !empty($trait['system']) || ( array_key_exists('active', $trait) && !$trait['active'] ) ) { return; } $name = trim( (string)( $trait['name'] ?? '' ) ); if ($name === '') { return; } $type = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); if ($type === 'binary') { $type = 'boolean'; } if ( !in_array( $type, ['number', 'boolean'], true ) ) { return; } $normalized = gameplayPartyTraitNormalizeName( $name ); if ($normalized === '') { return; } if (!isset($catalog[$normalized])) { $catalog[$normalized] = [ 'name' => $name, 'data_type' => $type, 'conflict' => false, ]; return; } if ( (string)$catalog[$normalized]['data_type'] !== $type ) { $catalog[$normalized]['conflict'] = true; } }; if ($playerTraitsOverride === null) { $stmt = $pdo->prepare( 'SELECT config FROM wf_games WHERE id = ? LIMIT 1' ); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); $config = $raw === false ? [] : gameplayRuntimeStateDecodeConfig( $raw ); $playerTraits = isset($config['player_traits']) && is_array($config['player_traits']) ? $config['player_traits'] : []; } else { $playerTraits = $playerTraitsOverride; } foreach ($playerTraits as $trait) { if (is_array($trait)) { $append( $catalog, $trait ); } } $stmt = $pdo->prepare( " SELECT config FROM wf_entities WHERE game_id = ? AND status = 'active' AND LOWER(entity_type) = 'character' ORDER BY id " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (is_array($trait)) { $append( $catalog, $trait ); } } } foreach ($catalog as $normalized => $definition) { if (!empty($definition['conflict'])) { unset($catalog[$normalized]); } } uasort( $catalog, static function ( array $a, array $b ): int { return strcasecmp( (string)$a['name'], (string)$b['name'] ); } ); return $catalog; } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } /* |-------------------------------------------------------------------------- | Status definitions / target metadata | WAYFARER_STATUSES_AUTHORING_V87 | WAYFARER_STATUS_OWNERS_V92 |-------------------------------------------------------------------------- */ function gameplayStatusDefinitions( PDO $pdo, int $gameId, bool $activeOnly = true ): array { if ($gameId <= 0) { return []; } $sql = " SELECT id, name, description, config, status FROM wf_statuses WHERE game_id = ? "; if ($activeOnly) { $sql .= " AND status = 'active'"; } $sql .= " ORDER BY name, id"; $stmt = $pdo->prepare($sql); $stmt->execute([$gameId]); $definitions = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $normalizeBoolean = static function ($value, bool $default): bool { if ($value === null || $value === '') { return $default; } if (is_bool($value)) { return $value; } return in_array( strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true ); }; $definitions[(string)$id] = [ 'id' => $id, 'name' => trim((string)($row['name'] ?? ('Status ' . $id))), 'description' => trim((string)($row['description'] ?? '')), 'status' => strtolower(trim((string)($row['status'] ?? 'draft'))), /* WAYFARER_STATUS_OWNERS_V92 */ 'applies_to' => in_array( strtolower(trim((string)($config['applies_to'] ?? 'character'))), ['character', 'item', 'both'], true ) ? strtolower(trim((string)($config['applies_to'] ?? 'character'))) : 'character', 'show_to_player' => $normalizeBoolean( $config['show_to_player'] ?? null, true ), /* WAYFARER_STATUS_LIFECYCLE_AUTHORING_V88 */ 'show_stage_to_player' => $normalizeBoolean( $config['show_stage_to_player'] ?? null, true ), 'show_time_remaining' => $normalizeBoolean( $config['show_time_remaining'] ?? null, false ), 'lifecycle' => isset($config['lifecycle']) && is_array($config['lifecycle']) ? $config['lifecycle'] : null, ]; } return $definitions; } /* WAYFARER_STATUS_OWNERS_V92 */ function gameplayStatusAppliesTo( array $definition, string $ownerType ): bool { $appliesTo = strtolower( trim((string)($definition['applies_to'] ?? 'character')) ); $ownerType = strtolower(trim($ownerType)); if ($appliesTo === 'both') { return in_array($ownerType, ['character', 'item'], true); } return $appliesTo === $ownerType; } function gameplayStatusTargetDefinition( string $scope, int $statusId, string $statusName, array $hierarchy, array $extra = [] ): array { return array_merge( [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'status', 'target_id' => (string)$statusId, 'trait_type' => 'boolean', 'data_type' => 'boolean', 'semantic_type' => 'status', 'helper' => 'Persistent Status: Active / Inactive', 'condition_operators' => [ 'equals', 'not_equals', ], 'effect_operations' => [ 'set', ], 'supports_random' => false, 'value_options' => [ '0' => 'Inactive', '1' => 'Active', ], 'group' => 'Statuses', 'label' => $statusName, 'hierarchy' => $hierarchy, ], $extra ); } /* |-------------------------------------------------------------------------- | Triggering Location Storylet Context | WAYFARER_TRIGGERING_LOCATION_CONTEXT_V103 |-------------------------------------------------------------------------- */ function gameplayTriggeringLocationContextTargets(PDO $pdo, int $gameId): array { $targets = [ 'triggering_location:name:name' => [ 'scope' => 'triggering_location', 'target_scope' => 'triggering_location', 'target_type' => 'name', 'target_id' => 'name', 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'triggering_location_name', 'condition_operators' => ['equals','not_equals'], 'effect_hidden' => true, 'supports_random' => false, 'group' => 'Runtime Context', 'label' => 'Triggering Location → Name', 'hierarchy' => [ ['key'=>'runtime_context','label'=>'Runtime Context'], ['key'=>'triggering_location','label'=>'Triggering Location'], ['key'=>'name','label'=>'Name'], ], ], ]; if ($gameId <= 0) return $targets; $stmt=$pdo->prepare("SELECT config FROM wf_locations WHERE game_id = ? ORDER BY id"); $stmt->execute([$gameId]); $seen=[]; foreach($stmt->fetchAll(PDO::FETCH_ASSOC) as $row){ $cfg=gameplayRuntimeStateDecodeConfig($row['config']??null); foreach(($cfg['traits']??[]) as $trait){ if(!is_array($trait) || (array_key_exists('active',$trait)&&!$trait['active'])) continue; $name=trim((string)($trait['name']??$trait['label']??'')); if($name==='') continue; $slug=strtolower(trim((string)preg_replace('/[^a-z0-9]+/i','_',$name),'_')); if($slug===''||isset($seen[$slug])) continue; $seen[$slug]=true; $type=strtolower(trim((string)($trait['type']??$trait['data_type']??'text'))); if($type==='binary')$type='boolean'; if(!in_array($type,['number','boolean','text'],true))$type='text'; $key='triggering_location:trait:'.$slug; $targets[$key]=[ 'scope'=>'triggering_location','target_scope'=>'triggering_location','target_type'=>'trait','target_id'=>$slug, 'trait_type'=>$type,'data_type'=>$type,'semantic_type'=>'triggering_location_trait','effect_hidden'=>true,'supports_random'=>false, 'condition_operators'=>$type==='number'?wayfarerConditionOperators():['equals','not_equals'], 'group'=>'Runtime Context','label'=>'Triggering Location → '.$name, 'hierarchy'=>[ ['key'=>'runtime_context','label'=>'Runtime Context'], ['key'=>'triggering_location','label'=>'Triggering Location'], ['key'=>'trait:'.$slug,'label'=>$name], ], ]; } } return $targets; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } /* WAYFARER_PARTY_TARGETS_V85 */ foreach ( gameplayPartyTraitCatalog( $pdo, $gameId, $playerTraits ) as $partyTrait ) { $partyName = (string)( $partyTrait['name'] ?? '' ); $partyDataType = (string)( $partyTrait['data_type'] ?? 'text' ); if ( $partyName === '' || !in_array( $partyDataType, ['number', 'boolean'], true ) ) { continue; } $partyTargetId = gameplayPartyTraitTargetId( $partyName ); $partyKey = 'party:trait:' . $partyTargetId; $targets[$partyKey] = [ 'scope' => 'party', 'target_scope' => 'party', 'target_type' => 'trait', 'target_id' => $partyTargetId, 'trait_type' => $partyDataType, 'data_type' => $partyDataType, 'semantic_type' => 'party_all_trait', 'helper' => 'Every current party member that exposes this Trait', 'condition_operators' => $partyDataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ], 'effect_operations' => $partyDataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ], 'supports_random' => $partyDataType === 'number', /* * A Party Condition needs an explicit Any/All quantifier model. * Until that exists, this aggregate target is Effect-only. */ 'condition_hidden' => true, 'group' => 'Party', 'label' => 'All Party Members → ' . $partyName, 'hierarchy' => [ [ 'key' => 'party', 'label' => 'Party', ], [ 'key' => 'all_party_members', 'label' => 'All Party Members', ], [ 'key' => $partyTargetId, 'label' => $partyName, ], ], ]; if ($partyDataType === 'boolean') { $targets[$partyKey]['value_options'] = [ '0' => 'False', '1' => 'True', ]; } } /* WAYFARER_STATUS_TARGETS_V87 */ $statusDefinitions = gameplayStatusDefinitions( $pdo, $gameId, true ); foreach ($statusDefinitions as $statusDefinition) { $statusId = (int)($statusDefinition['id'] ?? 0); $statusName = trim((string)($statusDefinition['name'] ?? '')); if ( $statusId <= 0 || $statusName === '' || !gameplayStatusAppliesTo($statusDefinition, 'character') ) { continue; } $partyStatusKey = 'party:status:' . $statusId; $targets[$partyStatusKey] = gameplayStatusTargetDefinition( 'party', $statusId, $statusName, [ [ 'key' => 'party', 'label' => 'Party', ], [ 'key' => 'all_party_members', 'label' => 'All Party Members', ], [ 'key' => 'status:' . $statusId, 'label' => 'Status — ' . $statusName, ], ], [ /* Party Status Conditions need explicit Any/All semantics. */ 'condition_hidden' => true, 'group' => 'Party', 'label' => 'All Party Members → Status — ' . $statusName, ] ); for ($slot = 0; $slot < $playerSlots; $slot++) { $playerStatusKey = 'player:' . $slot . ':status:' . $statusId; $targets[$playerStatusKey] = gameplayStatusTargetDefinition( 'player:' . $slot, $statusId, $statusName, [ [ 'key' => 'player', 'label' => 'Player Character', ], [ 'key' => 'player:' . $slot, 'label' => 'Player ' . ($slot + 1), ], [ 'key' => 'status:' . $statusId, 'label' => 'Status — ' . $statusName, ], ], [ /* Player slots remain a runtime compatibility address. */ 'condition_hidden' => true, 'effect_hidden' => true, ] ); } } /* * Numbered Player slots remain runtime-compatible for existing authored * data, but they are implementation slots rather than useful new targets. * New authoring uses Storylet Binding -> Random Party Member or * Party -> All Party Members instead. */ for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); $playerKey = 'player:' . $slot . ':trait:' . $traitId; if ( $traitId !== '' && isset($targets[$playerKey]) ) { $targets[$playerKey]['condition_hidden'] = true; $targets[$playerKey]['effect_hidden'] = true; } } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'min' => 0, /* WAYFARER_HUD_ITEM_QUANTITY_BOUNDS_V94 */ 'max' => isset($config['system']['quantity']['max']) && is_numeric($config['system']['quantity']['max']) ? (float)$config['system']['quantity']['max'] : null, 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } /* WAYFARER_STATUS_OWNERS_V92 */ if (in_array($entityType, ['character', 'item'], true)) { foreach ($statusDefinitions as $statusDefinition) { $statusId = (int)($statusDefinition['id'] ?? 0); $statusName = trim((string)($statusDefinition['name'] ?? '')); if ( $statusId <= 0 || $statusName === '' || !gameplayStatusAppliesTo($statusDefinition, $entityType) ) { continue; } $statusKey = 'entity:' . $id . ':status:' . $statusId; $ownerLabel = $entityType === 'item' ? 'Item' : 'Character'; $groupLabel = $entityType === 'item' ? 'Items' : 'Characters'; $targets[$statusKey] = gameplayStatusTargetDefinition( 'entity:' . $id, $statusId, $statusName, [ [ 'key' => $entityType, 'label' => $ownerLabel, ], [ 'key' => 'entity:' . $id, 'label' => $name, ], [ 'key' => 'status:' . $statusId, 'label' => 'Status — ' . $statusName, ], ], [ 'group' => $groupLabel, 'label' => $name . ' → Status — ' . $statusName, ] ); } } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ /* |-------------------------------------------------------------------------- | Navigation Condition Targets |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITION_TARGETS_V75 | WAYFARER_LIVE_LOCATION_ANCHORS_V76 | | Numeric Position Conditions are Region-qualified. | | New Location Conditions reference wf_locations.id as target_id. The | comparison itself is against zero because runtime resolves the target to: | | current position - current anchor position | | That makes a Location a live spatial anchor: | | = 0 => At | != 0 => Not at | < 0 => Before | <= 0 => At or before | > 0 => After | >= 0 => At or after | | The V75 snapshot target navigation:location:location remains hidden for | backward compatibility only. | */ function gameplayConditionNavigationPositionToken( $value ): string { if (!is_numeric($value)) { return ''; } $number = (float)$value; if (is_finite($number) && floor($number) == $number) { return (string)((int)$number); } $token = rtrim( rtrim( sprintf('%.12F', $number), '0' ), '.' ); return $token === '-0' ? '0' : $token; } function gameplayNavigationConditionTargets( PDO $pdo, int $gameId ): array { $targets = []; /* * Compatibility only. Existing saved unqualified Position Conditions * still render and save, but cannot be newly authored. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position_legacy', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'group' => 'Navigation', 'label' => 'Position (legacy unqualified)', 'condition_hidden' => true, ]; /* * Compatibility only. V75 stored Region + Position in the value itself. * New V76 Conditions never use this target. */ $targets['navigation:location:location'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => 'location', 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'navigation_location_v75_snapshot', 'helper' => 'V75 snapshot', 'condition_operators' => [ 'equals', 'not_equals', ], 'group' => 'Navigation', 'label' => 'Location (legacy V75 snapshot)', 'condition_hidden' => true, ]; if ($gameId <= 0) { return $targets; } /* * Fixed numeric Position, explicitly qualified to one linear Region. */ $regionStmt = $pdo->prepare( " SELECT id, name, navigation_type FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $regionStmt->execute([ $gameId ]); foreach ( $regionStmt->fetchAll(PDO::FETCH_ASSOC) as $region ) { $regionId = (int)($region['id'] ?? 0); if ($regionId <= 0) { continue; } if ( strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) !== 'linear' ) { continue; } $regionName = trim( (string)( $region['name'] ?? ('Region ' . $regionId) ) ); $scope = 'navigation:' . $regionId; $key = $scope . ':position:position'; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_region_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'group' => 'Navigation — Positions', 'label' => $regionName, ]; } /* * Named Locations are LIVE references. Each Location is its own target, * identified by wf_locations.id. Position is shown only as authoring help; * it is resolved again from current Location config at runtime. */ $locationStmt = $pdo->prepare( " SELECT l.id, l.name, l.config, l.region_id, r.name AS region_name, r.navigation_type FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $locationStmt->execute([ $gameId ]); foreach ( $locationStmt->fetchAll(PDO::FETCH_ASSOC) as $location ) { if ( strtolower( trim( (string)( $location['navigation_type'] ?? '' ) ) ) !== 'linear' ) { continue; } $locationId = (int)( $location['id'] ?? 0 ); if ($locationId <= 0) { continue; } $config = gameplayRuntimeStateDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $position = gameplayConditionNavigationPositionToken( $config['position'] ); if ($position === '') { continue; } $locationName = trim( (string)( $location['name'] ?? ('Location ' . $locationId) ) ); $regionName = trim( (string)( $location['region_name'] ?? 'Region' ) ); $key = 'navigation:location:' . $locationId; $targets[$key] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => (string)$locationId, 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_location_anchor', 'helper' => $regionName . ' · Position ' . $position, 'condition_operators' => [ 'equals', 'not_equals', 'less_than', 'less_than_or_equal', 'greater_than', 'greater_than_or_equal', ], 'condition_operator_labels' => [ 'equals' => 'At', 'not_equals' => 'Not at', 'less_than' => 'Before', 'less_than_or_equal' => 'At or before', 'greater_than' => 'After', 'greater_than_or_equal' => 'At or after', ], /* * The runtime value is a signed delta from this anchor, so every * comparison is against zero. Keep that implementation detail out * of the authoring UI while still posting a normal Condition value. */ 'value_options' => [ '0' => 'Anchor', ], 'condition_hide_value' => true, 'group' => 'Navigation — Locations', 'label' => $locationName, ]; } return $targets; } /* * Allows Condition POST validation to recognize V76 navigation-only target * shapes even when a caller supplied the older universal Effect catalog. */ /* |-------------------------------------------------------------------------- | Storylet Navigation Effect targets | WAYFARER_STORYLET_ROOT_CONNECTIONS_V82 |-------------------------------------------------------------------------- | | Explicit Storylet navigation is an Effect-only target. A Storylet may | continue directly to another root Storylet after its other Effects resolve. | Child Storylets remain structural Branches and are deliberately not exposed | as destinations here. */ function gameplayStoryletNavigationEffectTargets( PDO $pdo, int $gameId, int $currentStoryletId = 0 ): array { $targets = []; if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, status FROM wf_storylets WHERE game_id = ? AND ( parent_storylet_id IS NULL OR parent_storylet_id = 0 ) ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $storylet ) { $storyletId = (int)( $storylet['id'] ?? 0 ); if ( $storyletId <= 0 || ( $currentStoryletId > 0 && $storyletId === $currentStoryletId ) ) { continue; } $name = trim( (string)( $storylet['name'] ?? '' ) ); if ($name === '') { $name = 'Storylet ' . $storyletId; } $status = strtolower( trim( (string)( $storylet['status'] ?? '' ) ) ); $helper = $status !== '' ? ucfirst($status) . ' root Storylet' : 'Root Storylet'; $key = 'storylet:transition:' . $storyletId; $targets[$key] = [ 'scope' => 'storylet', 'target_scope' => 'storylet', 'target_type' => 'transition', 'target_id' => (string)$storyletId, 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'storylet_transition', 'helper' => $helper, 'effect_operations' => [ 'continue', ], 'supports_random' => false, 'group' => 'Storylet Navigation', 'label' => $name . ' → Continue To', 'hierarchy' => [ [ 'key' => 'storylet', 'label' => 'Storylet', ], [ 'key' => 'storylet:' . $storyletId, 'label' => $name, ], [ 'key' => 'continue_to', 'label' => 'Continue To', ], ], ]; } return $targets; } /* |-------------------------------------------------------------------------- | Hierarchical target-ready Time Conditions | WAYFARER_HIERARCHICAL_TARGETS_V80 |-------------------------------------------------------------------------- | | The hierarchy UI consumes a generic target catalog. New systems can opt in | simply by supplying a three-level hierarchy array: | | Type -> Specific Target -> Property | | Current Game Time is Condition-only. Effects do not modify the clock here. | Calendar / Seasons can later register additional Time properties without | changing the picker. */ function gameplayTimeConditionTargets( PDO $pdo, int $gameId ): array { $targets = []; if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ($raw === false) { return $targets; } $config = gameplayRuntimeStateDecodeConfig( $raw ); $time = isset($config['time']) && is_array($config['time']) ? $config['time'] : []; $mode = strtolower( trim( (string)( $time['mode'] ?? 'none' ) ) ); if ( $mode !== 'days' && $mode !== 'hours' ) { return $targets; } $base = [ 'scope' => 'time', 'target_scope' => 'time', 'target_type' => 'clock', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'game_time', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'group' => 'Time', ]; $targets['time:clock:day'] = array_merge( $base, [ 'target_id' => 'day', 'label' => 'Game Clock → Day', 'hierarchy' => [ [ 'key' => 'time', 'label' => 'Time', ], [ 'key' => 'game_clock', 'label' => 'Game Clock', ], [ 'key' => 'day', 'label' => 'Day', ], ], ] ); if ($mode === 'hours') { $targets['time:clock:hour'] = array_merge( $base, [ 'target_id' => 'hour', 'label' => 'Game Clock → Hour', 'hierarchy' => [ [ 'key' => 'time', 'label' => 'Time', ], [ 'key' => 'game_clock', 'label' => 'Game Clock', ], [ 'key' => 'hour', 'label' => 'Hour', ], ], ] ); } return $targets; } function gameplaySyntheticTimeConditionTarget( string $target ): ?array { if ($target === 'time:clock:day') { return [ 'scope' => 'time', 'target_scope' => 'time', 'target_type' => 'clock', 'target_id' => 'day', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'game_time', 'condition_operators' => wayfarerConditionOperators(), ]; } if ($target === 'time:clock:hour') { return [ 'scope' => 'time', 'target_scope' => 'time', 'target_type' => 'clock', 'target_id' => 'hour', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'game_time', 'condition_operators' => wayfarerConditionOperators(), ]; } return null; } function gameplaySyntheticNavigationConditionTarget( string $target ): ?array { if ( preg_match( '/^navigation:([1-9][0-9]*):position:position$/', $target, $match ) === 1 ) { $scope = 'navigation:' . (int)$match[1]; return [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_region_position', 'condition_operators' => wayfarerConditionOperators(), ]; } if ( preg_match( '/^navigation:location:([1-9][0-9]*)$/', $target, $match ) === 1 ) { return [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => (string)((int)$match[1]), 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_location_anchor', 'condition_operators' => [ 'equals', 'not_equals', 'less_than', 'less_than_or_equal', 'greater_than', 'greater_than_or_equal', ], ]; } /* V75 snapshot compatibility. */ if ($target === 'navigation:location:location') { return [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => 'location', 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'navigation_location_v75_snapshot', 'condition_operators' => [ 'equals', 'not_equals', ], ]; } return null; } function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return array_replace( gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ), gameplayNavigationConditionTargets( $pdo, $gameId ), gameplayTimeConditionTargets( $pdo, $gameId ) ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; /* * WAYFARER_LOCATION_CONDITION_TARGETS_V75 * * Older callers may still pass the universal Effect target catalog. * Region-qualified Position and Location targets are Condition-only, * so synthesize their definitions here when needed. */ if ( $target !== '' && !isset($allowedTargets[$target]) ) { $syntheticTarget = gameplaySyntheticNavigationConditionTarget( $target ); if ( $syntheticTarget === null && function_exists( 'gameplaySyntheticTimeConditionTarget' ) ) { $syntheticTarget = gameplaySyntheticTimeConditionTarget( $target ); } if ($syntheticTarget !== null) { $allowedTargets[$target] = $syntheticTarget; } } if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: includes/gameplay/game-endings.php ================================================================================ [], 'error' => 'Each Ending must have a Title.', ]; } if ( $priorityRaw === '' || filter_var( $priorityRaw, FILTER_VALIDATE_INT ) === false ) { return [ 'endings' => [], 'error' => 'Ending Priority must be a whole number.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, $allowedTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'endings' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'endings' => [], 'error' => 'Each Ending must have at least one Condition.', ]; } if ($imageMediaId > 0) { if ($gameId <= 0) { return [ 'endings' => [], 'error' => 'Save the Game before assigning an Ending image.', ]; } $mediaStmt = $pdo->prepare( " SELECT id FROM wf_media WHERE id = ? AND game_id = ? AND media_type = 'image' LIMIT 1 " ); $mediaStmt->execute([ $imageMediaId, $gameId, ]); if ( $mediaStmt->fetchColumn() === false ) { return [ 'endings' => [], 'error' => 'An Ending uses an invalid image asset.', ]; } } $id = trim( (string)( $postedEnding['id'] ?? '' ) ); if ( $id === '' || !preg_match( '/^ending_[A-Za-z0-9_-]+$/', $id ) ) { $id = wayfarerEndingMakeId(); } if (isset($seenIds[$id])) { return [ 'endings' => [], 'error' => 'Ending IDs must be unique.', ]; } $seenIds[$id] = true; $endings[] = [ 'id' => $id, 'title' => $title, 'text' => $text, 'image_media_id' => $imageMediaId, 'priority' => (int)$priorityRaw, 'conditions' => $conditionResult['node'], ]; } return [ 'endings' => $endings, 'error' => '', ]; } function wayfarerRenderGameEndingsEditor( PDO $pdo, int $gameId, array $endings, array $gameTraits ): void { $imageAssets = []; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT id, name FROM wf_media WHERE game_id = ? AND media_type = 'image' ORDER BY name, id " ); $stmt->execute([ $gameId ]); $imageAssets = $stmt->fetchAll( PDO::FETCH_ASSOC ); } $conditionTargets = gameplayConditionGameWideTargets( $pdo, $gameId, $gameTraits ); ?>
$ending ): ?>
Ending

Conditions

Required. When these conditions become true, this Ending becomes terminal. Higher Priority wins if more than one Ending becomes true at the same time.

$target) { if (!is_array($target)) { continue; } if ( strtolower(trim((string)($target['data_type'] ?? ''))) !== 'number' || strtolower(trim((string)($target['target_type'] ?? ''))) !== 'trait' ) { continue; } $scope = trim((string)($target['target_scope'] ?? $target['scope'] ?? '')); $targetId = trim((string)($target['target_id'] ?? '')); /* * V94 is deliberately direct-state only. Party aggregation is a useful * future source, but it needs explicit average/min/max semantics first. */ if ( $targetId === '' || !( $scope === 'game' || preg_match('/^(region|location|entity):[1-9][0-9]*$/', $scope) === 1 ) ) { continue; } $label = trim((string)($target['label'] ?? $key)); $semantic = trim((string)($target['semantic_type'] ?? 'trait')); $hudLabel = $label; if (strpos($label, ' → ') !== false) { $parts = explode(' → ', $label); if ($semantic === 'item_quantity' && count($parts) >= 2) { $hudLabel = trim((string)$parts[count($parts) - 2]); } else { $hudLabel = trim((string)end($parts)); } } $min = isset($target['min']) && is_numeric($target['min']) ? (float)$target['min'] : null; $max = isset($target['max']) && is_numeric($target['max']) ? (float)$target['max'] : null; $sources[(string)$key] = [ 'key' => (string)$key, 'scope' => $scope, 'target_id' => $targetId, 'label' => $label !== '' ? $label : (string)$key, 'hud_label' => $hudLabel !== '' ? $hudLabel : ($label !== '' ? $label : (string)$key), 'min' => $min, 'max' => $max, 'bounded' => $min !== null && $max !== null && $max > $min, 'semantic_type' => $semantic, ]; } uasort( $sources, static function (array $a, array $b): int { return strcasecmp((string)$a['label'], (string)$b['label']); } ); return $sources; } function gameplayHudLegacyRegionComponents( PDO $pdo, int $gameId ): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? ORDER BY id " ); $stmt->execute([$gameId]); $merged = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $regionId = (int)($row['id'] ?? 0); if ($regionId <= 0) { continue; } $config = gameplayRuntimeStateDecodeConfig($row['config'] ?? null); $hud = isset($config['hud']) && is_array($config['hud']) ? $config['hud'] : []; if (empty($hud['enabled']) || empty($hud['components']) || !is_array($hud['components'])) { continue; } foreach ($hud['components'] as $component) { if (!is_array($component)) { continue; } $legacyScope = trim((string)($component['source_scope'] ?? '')); $sourceId = trim((string)($component['source_id'] ?? '')); if ($sourceId === '') { continue; } $scope = $legacyScope === 'region' ? 'region:' . $regionId : $legacyScope; if ($scope !== 'game' && preg_match('/^region:[1-9][0-9]*$/', $scope) !== 1) { continue; } $sourceKey = $scope . ':trait:' . $sourceId; $label = trim((string)($component['label'] ?? '')); $type = strtolower(trim((string)($component['type'] ?? 'progress_bar'))); if (!in_array($type, ['number', 'progress_bar'], true)) { $type = 'progress_bar'; } $mergeKey = implode('|', [ $type, $sourceKey, $label, !empty($component['show_value']) ? '1' : '0', ]); if (!isset($merged[$mergeKey])) { $merged[$mergeKey] = [ 'id' => trim((string)($component['id'] ?? '')), 'type' => $type, 'source' => $sourceKey, 'label' => $label, 'show_value' => !empty($component['show_value']), /* Legacy Region HUD was persistent outside Storylets. */ 'show_in' => 'both', 'regions' => [], ]; } $merged[$mergeKey]['regions'][] = $regionId; $merged[$mergeKey]['regions'] = array_values(array_unique(array_map('intval', $merged[$mergeKey]['regions']))); } } return array_values($merged); } function gameplayBuildGameHudFromPost( array $postedComponents, array $sources, array $validRegionIds ): array { $components = []; $validRegionIds = array_values(array_unique(array_map('intval', $validRegionIds))); $validRegionMap = array_fill_keys($validRegionIds, true); foreach (array_values($postedComponents) as $rowIndex => $posted) { if (!is_array($posted)) { continue; } $type = strtolower(trim((string)($posted['type'] ?? 'number'))); if (!in_array($type, ['number', 'progress_bar'], true)) { return [ 'components' => [], 'editor_components' => array_values($postedComponents), 'error' => 'HUD item ' . ($rowIndex + 1) . ' has an unsupported display type.', ]; } /* WAYFARER_HUD_SURFACES_V95 */ $showIn = strtolower(trim((string)($posted['show_in'] ?? 'travel'))); if (!in_array($showIn, ['travel', 'action', 'both'], true)) { return [ 'components' => [], 'editor_components' => array_values($postedComponents), 'error' => 'HUD item ' . ($rowIndex + 1) . ' has an unsupported Show In value.', ]; } $sourceKey = trim((string)($posted['source'] ?? '')); $source = $sources[$sourceKey] ?? null; if (!is_array($source)) { return [ 'components' => [], 'editor_components' => array_values($postedComponents), 'error' => 'HUD item ' . ($rowIndex + 1) . ' must select a numeric source.', ]; } if ($type === 'progress_bar' && empty($source['bounded'])) { return [ 'components' => [], 'editor_components' => array_values($postedComponents), 'error' => 'HUD item ' . ($rowIndex + 1) . ' uses a Progress Bar, but its source does not have both a numeric Minimum and Maximum.', ]; } $regions = []; foreach ((array)($posted['regions'] ?? []) as $regionId) { $regionId = (int)$regionId; if ($regionId > 0 && isset($validRegionMap[$regionId])) { $regions[] = $regionId; } } $regions = array_values(array_unique($regions)); $id = trim((string)($posted['id'] ?? '')); if ($id === '') { $id = 'hud_' . bin2hex(random_bytes(8)); } $components[] = [ 'id' => $id, 'type' => $type, 'source' => $sourceKey, 'label' => trim((string)($posted['label'] ?? '')), 'show_value' => $type === 'number' ? true : !empty($posted['show_value']), 'show_in' => $showIn, /* Empty means every Region. */ 'regions' => $regions, ]; } return [ 'components' => $components, 'editor_components' => $components, 'error' => '', ]; } ================================================================================ FILE: includes/gameplay/starting-choices.php ================================================================================ prepare( " SELECT id, name, config FROM wf_entities WHERE game_id = ? AND status = 'active' AND LOWER(entity_type) = 'item' ORDER BY name, id " ); $stmt->execute([ $gameId ]); $rows = $stmt->fetchAll( PDO::FETCH_ASSOC ); $targets = []; foreach ($rows as $row) { $entityId = (int)( $row['id'] ?? 0 ); if ($entityId <= 0) { continue; } $config = []; if ( isset($row['config']) && $row['config'] !== null && $row['config'] !== '' ) { $decoded = json_decode( (string)$row['config'], true ); if (is_array($decoded)) { $config = $decoded; } } /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Quantity is universal for Item Entities. Its definition is Wayfarer * schema, while this Item's initial/max values live in config.system. */ $quantityTraitId = 'sys_item_quantity'; $targets[] = [ 'entity_id' => $entityId, 'name' => trim( (string)( $row['name'] ?? ( 'Item ' . $entityId ) ) ), 'target_scope' => 'entity:' . $entityId, 'target_type' => 'trait', 'target_id' => $quantityTraitId, 'target_system_key' => 'quantity', ]; } return $targets; } function wayfarerStartingChoiceEffectTarget( array $effect ): string { if (!empty($effect['target'])) { return (string)$effect['target']; } $scope = (string)( $effect['target_scope'] ?? '' ); $type = (string)( $effect['target_type'] ?? '' ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); if ( $scope !== '' && $type !== '' && $targetId !== '' ) { $canonical = $scope . ':' . $type . ':' . $targetId; $runtimeTargets = $GLOBALS[ 'wayfarer_starting_runtime_targets' ] ?? []; if ( is_array($runtimeTargets) && isset($runtimeTargets[$canonical]) ) { return $canonical; } } /* * Legacy setup targets remain readable. */ if ( $scope === 'game' && $type === 'trait' && $targetId !== '' ) { return 'game:' . $targetId; } if ( $scope === 'player' && $type === 'trait' && $targetId !== '' ) { $selector = strtolower( trim( (string)( $effect['target_selector'] ?? '' ) ) ); if ($selector === 'all') { return 'player_all:' . $targetId; } if ( $selector === 'slot' && array_key_exists( 'target_slot', $effect ) ) { return 'player_slot:' . max( 0, (int)$effect['target_slot'] ) . ':' . $targetId; } } if ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope ) === 1 ) { return $scope . ':' . $targetId; } return ''; } /* * Build the complete target map used by both authoring validation and the * Starting Choice target selector. */ function wayfarerStartingChoiceAllowedTargets( array $gameTraits, array $playerTraits = [], int $playerSlots = 0, array $itemQuantityTargets = [], array $runtimeTargets = [] ): array { /* WAYFARER_STARTING_CHOICES_UNIVERSAL_TARGETS_V56 */ if ($runtimeTargets) { return $runtimeTargets; } $allowed = []; foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $trait ) { $traitId = (string)( $trait['id'] ?? '' ); if ($traitId === '') { continue; } $allowed[ 'game:' . $traitId ] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $traitId, 'group' => 'Game Traits', 'label' => 'Game → ' . (string)( $trait['name'] ?? $traitId ), ]; } $playerTargets = gameplayNumericTraitTargets( $playerTraits, 'player' ); $playerSlots = max( 0, $playerSlots ); if ( $playerSlots > 0 && !empty($playerTargets) ) { foreach ($playerTargets as $trait) { $traitId = (string)( $trait['id'] ?? '' ); if ($traitId === '') { continue; } $allowed[ 'player_all:' . $traitId ] = [ 'target_scope' => 'player', 'target_type' => 'trait', 'target_id' => $traitId, 'target_selector' => 'all', 'group' => 'Player Traits — All Players', 'label' => 'All Players → ' . (string)( $trait['name'] ?? $traitId ), ]; } for ( $slot = 0; $slot < $playerSlots; $slot++ ) { foreach ($playerTargets as $trait) { $traitId = (string)( $trait['id'] ?? '' ); if ($traitId === '') { continue; } $allowed[ 'player_slot:' . $slot . ':' . $traitId ] = [ 'target_scope' => 'player', 'target_type' => 'trait', 'target_id' => $traitId, 'target_selector' => 'slot', 'target_slot' => $slot, 'group' => 'Player Character ' . ($slot + 1), 'label' => 'Player ' . ($slot + 1) . ' → ' . (string)( $trait['name'] ?? $traitId ), ]; } } } foreach ($itemQuantityTargets as $itemTarget) { if (!is_array($itemTarget)) { continue; } $scope = trim( (string)( $itemTarget['target_scope'] ?? '' ) ); $targetId = trim( (string)( $itemTarget['target_id'] ?? '' ) ); $entityId = (int)( $itemTarget['entity_id'] ?? 0 ); if ( $entityId <= 0 || !preg_match( '/^entity:[1-9][0-9]*$/', $scope ) || $targetId === '' ) { continue; } $key = $scope . ':' . $targetId; $allowed[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $targetId, 'target_system_key' => 'quantity', 'group' => 'Items', 'label' => (string)( $itemTarget['name'] ?? ( 'Item ' . $entityId ) ) . ' → Quantity', ]; } return $allowed; } function wayfarerBuildStartingChoicesFromPost( array $postedChoices, array $gameTraits, array $playerTraits = [], int $playerSlots = 0, array $itemQuantityTargets = [], array $runtimeTargets = [] ): array { $allowedTargets = wayfarerStartingChoiceAllowedTargets( $gameTraits, $playerTraits, $playerSlots, $itemQuantityTargets, $runtimeTargets ); /* * Legacy migration map: * * V52 allowed authors to move a definition from Game Traits to Player * Traits while keeping its Trait ID, but V36 Starting Choices still held * game:TRAIT_ID. When that exact ID is now a Player Trait, interpret the * old target as All Player Characters. */ $legacyPlayerTraitTargets = []; foreach ( gameplayNumericTraitTargets( $playerTraits, 'player' ) as $trait ) { $traitId = (string)( $trait['id'] ?? '' ); if ($traitId === '') { continue; } $legacyPlayerTraitTargets[ 'game:' . $traitId ] = 'player_all:' . $traitId; } $choices = []; $seenChoiceIds = []; foreach ( array_values($postedChoices) as $choiceIndex => $postedChoice ) { if (!is_array($postedChoice)) { continue; } $prompt = trim( (string)( $postedChoice['prompt'] ?? '' ) ); $postedOptions = isset($postedChoice['options']) && is_array($postedChoice['options']) ? array_values( $postedChoice['options'] ) : []; /* * Completely blank newly-added card: ignore it. */ if ( $prompt === '' && $postedOptions === [] ) { continue; } if ($prompt === '') { return [ 'choices' => [], 'error' => 'Each Starting Choice must have a prompt.', ]; } $choiceId = wayfarerStartingChoiceValidId( $postedChoice['id'] ?? null ) ? (string)$postedChoice['id'] : wayfarerStartingChoiceMakeId( 'choice' ); if (isset($seenChoiceIds[$choiceId])) { return [ 'choices' => [], 'error' => 'Starting Choice IDs must be unique.', ]; } $seenChoiceIds[$choiceId] = true; $required = !empty( $postedChoice['required'] ); $options = []; $seenOptionIds = []; foreach ( $postedOptions as $optionIndex => $postedOption ) { if (!is_array($postedOption)) { continue; } $label = trim( (string)( $postedOption['label'] ?? '' ) ); $description = trim( (string)( $postedOption['description'] ?? '' ) ); $postedEffects = isset($postedOption['effects']) && is_array($postedOption['effects']) ? array_values( $postedOption['effects'] ) : []; if ( $label === '' && $description === '' && $postedEffects === [] ) { continue; } if ($label === '') { return [ 'choices' => [], 'error' => 'Each Starting Choice option must have a label.', ]; } $optionId = wayfarerStartingChoiceValidId( $postedOption['id'] ?? null ) ? (string)$postedOption['id'] : wayfarerStartingChoiceMakeId( 'option' ); if (isset($seenOptionIds[$optionId])) { return [ 'choices' => [], 'error' => 'Starting Choice option IDs must be unique within a choice.', ]; } $seenOptionIds[$optionId] = true; $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $operation = strtolower( trim( (string)( $postedEffect['operation'] ?? '' ) ) ); $valueMode = strtolower( trim( (string)( $postedEffect['value_mode'] ?? 'fixed' ) ) ); $valueRaw = trim( (string)( $postedEffect['value'] ?? '' ) ); $minRaw = trim( (string)( $postedEffect['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedEffect['value_max'] ?? '' ) ); /* * Empty newly-added effect row. */ if ( $target === '' && $operation === '' && $valueRaw === '' && $minRaw === '' && $maxRaw === '' ) { continue; } /* * Preserve the intent of a Trait moved from Game -> Player. */ if ( !isset($allowedTargets[$target]) && isset( $legacyPlayerTraitTargets[$target] ) && isset( $allowedTargets[ $legacyPlayerTraitTargets[$target] ] ) ) { $target = $legacyPlayerTraitTargets[$target]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'choices' => [], 'error' => 'Each Starting Choice effect must use a valid runtime-state target.', ]; } /* WAYFARER_STARTING_CHOICE_DATATYPE_EFFECTS_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $allowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); if ( !in_array( $operation, $allowedOperations, true ) ) { return [ 'choices' => [], 'error' => 'That Starting Choice operation is not valid for the selected target.', ]; } $supportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$supportsRandom ) ) { return [ 'choices' => [], 'error' => $supportsRandom ? 'Starting Choice value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $effect = [ 'target_scope' => (string)$targetDefinition['target_scope'], 'target_type' => (string)( $targetDefinition['target_type'] ?? 'trait' ), 'target_id' => (string)$targetDefinition['target_id'], 'operation' => $operation, 'value_mode' => $valueMode, ]; if ($valueMode === 'random') { if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'choices' => [], 'error' => 'Random Starting Choice effects require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'choices' => [], 'error' => 'Random Starting Choice Min cannot be greater than Max.', ]; } $effect['value_min'] = $minimum; $effect['value_max'] = $maximum; } else { if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'choices' => [], 'error' => 'Numeric Starting Choice effects require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'choices' => [], 'error' => 'Boolean Starting Choice effects must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $effect['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $effect['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } if ( !empty( $targetDefinition[ 'target_system_key' ] ) ) { $effect['target_system_key'] = (string)$targetDefinition[ 'target_system_key' ]; } if ( $targetDefinition['target_scope'] === 'player' ) { $effect['target_selector'] = (string)( $targetDefinition[ 'target_selector' ] ?? 'all' ); if ( $effect['target_selector'] === 'slot' ) { $effect['target_slot'] = (int)( $targetDefinition[ 'target_slot' ] ?? 0 ); } } $effects[] = $effect; } $options[] = [ 'id' => $optionId, 'label' => $label, 'description' => $description, 'effects' => $effects, ]; } if ($options === []) { return [ 'choices' => [], 'error' => 'Each Starting Choice must have at least one option.', ]; } $choices[] = [ 'id' => $choiceId, 'prompt' => $prompt, 'required' => $required, 'options' => $options, ]; } return [ 'choices' => $choices, 'error' => '', ]; } function wayfarerRenderStartingChoiceTargetOptions( array $gameTraits, array $playerTraits = [], int $playerSlots = 0, array $itemQuantityTargets = [], string $selected = '', array $runtimeTargets = [] ): void { $targets = wayfarerStartingChoiceAllowedTargets( $gameTraits, $playerTraits, $playerSlots, $itemQuantityTargets, $runtimeTargets ); /* * If an existing option still stores game:TRAIT_ID but that ID now lives * under Player Traits, present it as All Players instead of losing the * selection in the editor. */ if ( $selected !== '' && !isset($targets[$selected]) && strpos( $selected, 'game:' ) === 0 ) { $traitId = substr( $selected, strlen('game:') ); $candidate = 'player_all:' . $traitId; if (isset($targets[$candidate])) { $selected = $candidate; } } echo ''; $grouped = []; foreach ($targets as $value => $target) { $group = (string)( $target['group'] ?? 'Traits' ); if (!isset($grouped[$group])) { $grouped[$group] = []; } $grouped[$group][$value] = $target; } foreach ($grouped as $group => $options) { echo ''; foreach ($options as $value => $target) { echo ''; } echo ''; } } function wayfarerRenderStartingChoiceEffect( array $effect = [] ): void { $target = wayfarerStartingChoiceEffectTarget( $effect ); $operation = (string)( $effect['operation'] ?? 'set' ); $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; $value = $effect['value'] ?? ''; $minimum = $effect['value_min'] ?? ''; $maximum = $effect['value_max'] ?? ''; ?>
>
Option
Starting Effects Optional. Effects may target any runtime-state target. Numeric targets support Fixed or Random values.
Starting Choice

Starting Choices

Optional one-of-many questions shown before the game begins. The selected option is saved in runtime state. Option effects can update any addressable runtime-state target during initialization.

prepare( " SELECT id, name, trigger_type, status FROM wf_storylets WHERE game_id = ? AND parent_storylet_id IS NULL ORDER BY name, id " ); $stmt->execute([$gameId]); $rows = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $rows[$id] = [ 'id' => $id, 'name' => trim((string)($row['name'] ?? ('Storylet ' . $id))), 'trigger_type' => strtolower(trim((string)($row['trigger_type'] ?? 'manual'))), 'status' => strtolower(trim((string)($row['status'] ?? 'draft'))), ]; } return $rows; } /* |-------------------------------------------------------------------------- | Status Owner Trait Effects | WAYFARER_STATUS_SUBJECT_EFFECTS_AUTHORING_V89 | WAYFARER_STATUS_OWNER_EFFECTS_AUTHORING_V92 |-------------------------------------------------------------------------- | | Lifecycle Effects address the object that owns the Status. A Status Owner | may be a Player/Character or an Item Entity. Trait identity remains stored | by normalized authored-name key and is resolved to the owner's actual Trait | ID at runtime. For a Status that applies to both Characters and Items, only | same-name/same-type Traits available to both owner classes are offered. | */ function gameplayStatusLifecycleOwnerTraitCatalog( PDO $pdo, int $gameId, string $appliesTo = 'character' ): array { $appliesTo = strtolower(trim($appliesTo)); if (!in_array($appliesTo, ['character', 'item', 'both'], true)) { $appliesTo = 'character'; } $characterByName = []; foreach (gameplayPartyTraitCatalog($pdo, $gameId) as $definition) { if (!is_array($definition)) { continue; } $name = trim((string)($definition['name'] ?? '')); $dataType = strtolower(trim((string)($definition['data_type'] ?? ''))); if ($name === '' || !in_array($dataType, ['number', 'boolean'], true)) { continue; } $normalized = gameplayPartyTraitNormalizeName($name); $characterByName[$normalized] = [ 'name' => $name, 'data_type' => $dataType, 'owner_type' => 'character', ]; } $itemByName = []; if (function_exists('gameplayStoryletBindingTraitCatalog')) { foreach (gameplayStoryletBindingTraitCatalog($pdo, $gameId, 'item') as $definition) { if (!is_array($definition)) { continue; } $name = trim((string)($definition['name'] ?? '')); $dataType = strtolower(trim((string)($definition['data_type'] ?? ''))); if ($name === '' || !in_array($dataType, ['number', 'boolean'], true)) { continue; } $normalizedName = gameplayPartyTraitNormalizeName($name); $itemByName[$normalizedName] = [ 'name' => $name, 'data_type' => $dataType, 'owner_type' => 'item', ]; } } $selected = []; if ($appliesTo === 'character') { $selected = $characterByName; } elseif ($appliesTo === 'item') { $selected = $itemByName; } else { foreach ($characterByName as $normalized => $definition) { if ( isset($itemByName[$normalized]) && (string)$itemByName[$normalized]['data_type'] === (string)$definition['data_type'] ) { $definition['owner_type'] = 'both'; $selected[$normalized] = $definition; } } } $catalog = []; foreach ($selected as $definition) { $name = (string)$definition['name']; $targetId = gameplayPartyTraitTargetId($name); $catalog[$targetId] = [ 'target_id' => $targetId, 'name' => $name, 'data_type' => (string)$definition['data_type'], 'owner_type' => (string)($definition['owner_type'] ?? $appliesTo), ]; } uasort( $catalog, static function (array $a, array $b): int { return strcasecmp( (string)($a['name'] ?? ''), (string)($b['name'] ?? '') ); } ); return $catalog; } /* Compatibility name for V89 code. */ function gameplayStatusLifecycleSubjectTraitCatalog( PDO $pdo, int $gameId, string $appliesTo = 'character' ): array { return gameplayStatusLifecycleOwnerTraitCatalog( $pdo, $gameId, $appliesTo ); } function gameplayStatusLifecycleBuildEffects( $postedEffects, array $traitCatalog, bool $recurring, string $context ): array { $postedEffects = is_array($postedEffects) ? array_values($postedEffects) : []; $effects = []; $effectKeys = []; foreach ($postedEffects as $effectIndex => $postedEffect) { if (!is_array($postedEffect)) { continue; } $targetId = trim((string)($postedEffect['target_id'] ?? '')); $operation = strtolower(trim((string)($postedEffect['operation'] ?? ''))); if ($targetId === '' && $operation === '') { continue; } $number = $effectIndex + 1; if ($targetId === '' || !isset($traitCatalog[$targetId])) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' must select a valid Status Owner Trait.', ]; } $definition = $traitCatalog[$targetId]; $dataType = (string)$definition['data_type']; $key = strtolower(trim((string)($postedEffect['key'] ?? ''))); if ($key === '') { try { $key = 'effect_' . bin2hex(random_bytes(6)); } catch (Throwable $exception) { $key = 'effect_' . substr(sha1(uniqid('', true)), 0, 12); } } if (preg_match('/^[a-z][a-z0-9_]{0,47}$/', $key) !== 1) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' has an invalid internal key. Remove and re-add that Effect.', ]; } if (isset($effectKeys[$key])) { return [ 'effects' => [], 'error' => $context . ' has duplicate Effect keys.', ]; } $effectKeys[$key] = true; $allowedOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', 'subtract_percent_current', 'subtract_percent_max', 'add_percent_max', ] : ['set']; if (!in_array($operation, $allowedOperations, true)) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' uses an operation that is not valid for the selected Trait.', ]; } $stored = [ 'key' => $key, 'target_id' => $targetId, 'trait_name' => (string)$definition['name'], 'data_type' => $dataType, 'operation' => $operation, ]; if ($recurring) { $interval = $postedEffect['interval_ticks'] ?? ''; if ( filter_var($interval, FILTER_VALIDATE_INT) === false || (int)$interval < 1 ) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' Every value must be at least 1 tick.', ]; } $stored['interval_ticks'] = (int)$interval; } if ($dataType === 'boolean') { $raw = strtolower(trim((string)($postedEffect['value'] ?? ''))); if (!in_array($raw, ['0', '1', 'true', 'false'], true)) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' must set the selected boolean Trait to True or False.', ]; } $stored['value_mode'] = 'fixed'; $stored['value'] = ($raw === '1' || $raw === 'true') ? 1 : 0; $effects[] = $stored; continue; } $valueMode = strtolower(trim((string)($postedEffect['value_mode'] ?? 'fixed'))); if (!in_array($valueMode, ['fixed', 'random'], true)) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' Value Type must be Fixed or Random Min–Max.', ]; } $percentOperation = in_array( $operation, ['subtract_percent_current', 'subtract_percent_max', 'add_percent_max'], true ); $stored['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim((string)($postedEffect['value_min'] ?? '')); $maxRaw = trim((string)($postedEffect['value_max'] ?? '')); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' Random Min–Max requires numeric values.', ]; } $minimum = (float)$minRaw; $maximum = (float)$maxRaw; if ($minimum > $maximum) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' Random Min cannot be greater than Max.', ]; } if ($percentOperation && $minimum < 0) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' percentage values cannot be negative.', ]; } $stored['value_min'] = gameplayEffectNormalizeNumber($minimum); $stored['value_max'] = gameplayEffectNormalizeNumber($maximum); } else { $valueRaw = trim((string)($postedEffect['value'] ?? '')); if ($valueRaw === '' || !is_numeric($valueRaw)) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' requires a numeric value.', ]; } $value = (float)$valueRaw; if ($percentOperation && $value < 0) { return [ 'effects' => [], 'error' => $context . ', Effect ' . $number . ' percentage values cannot be negative.', ]; } $stored['value'] = gameplayEffectNormalizeNumber($value); } $effects[] = $stored; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayBuildStatusLifecycleFromPost( PDO $pdo, int $gameId, $postedLifecycle, string $appliesTo = 'character' ): array { $postedLifecycle = is_array($postedLifecycle) ? $postedLifecycle : []; $mode = strtolower(trim((string)($postedLifecycle['mode'] ?? 'manual'))); if (!in_array($mode, ['manual', 'progression'], true)) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Progression Mode must be Manual or Timed Stages.', ]; } if ($mode === 'manual') { return [ 'lifecycle' => null, 'editor' => [ 'mode' => 'manual', 'stages' => [], ], 'error' => '', ]; } $postedStages = isset($postedLifecycle['stages']) && is_array($postedLifecycle['stages']) ? array_values($postedLifecycle['stages']) : []; if ($postedStages === []) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Timed Status progression needs at least one Stage.', ]; } $stageKeys = []; $stages = []; foreach ($postedStages as $stageIndex => $postedStage) { if (!is_array($postedStage)) { continue; } $stageNumber = $stageIndex + 1; $key = gameplayStatusLifecycleNormalizeKey( (string)($postedStage['key'] ?? '') ); if ( $key === '' || preg_match('/^[a-z][a-z0-9_]{0,47}$/', $key) !== 1 ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' has an invalid internal key. Remove and re-add that Stage.', ]; } if (isset($stageKeys[$key])) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage keys must be unique.', ]; } $name = trim((string)($postedStage['name'] ?? '')); if ($name === '') { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' needs a Stage Name.', ]; } $nameLength = function_exists('mb_strlen') ? mb_strlen($name, 'UTF-8') : strlen($name); if ($nameLength > 80) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' name must be 80 characters or fewer.', ]; } $durationMode = strtolower(trim((string)($postedStage['duration_mode'] ?? 'fixed'))); if (!in_array($durationMode, ['fixed', 'random'], true)) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' has an invalid Duration Mode.', ]; } if ($durationMode === 'fixed') { $value = $postedStage['duration_value'] ?? ''; if ( filter_var($value, FILTER_VALIDATE_INT) === false || (int)$value < 1 ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' Fixed Duration must be at least 1 tick.', ]; } $duration = [ 'mode' => 'fixed', 'value' => (int)$value, ]; } else { $min = $postedStage['duration_min'] ?? ''; $max = $postedStage['duration_max'] ?? ''; if ( filter_var($min, FILTER_VALIDATE_INT) === false || filter_var($max, FILTER_VALIDATE_INT) === false || (int)$min < 1 || (int)$max < 1 || (int)$min > (int)$max ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' Random Duration needs whole-number Min/Max ticks of at least 1, with Min no greater than Max.', ]; } $duration = [ 'mode' => 'random', 'min' => (int)$min, 'max' => (int)$max, ]; } $stageKeys[$key] = true; $stages[] = [ 'key' => $key, 'name' => $name, 'duration' => $duration, '_posted_entry_effects' => isset($postedStage['entry_effects']) && is_array($postedStage['entry_effects']) ? array_values($postedStage['entry_effects']) : [], '_posted_tick_effects' => isset($postedStage['tick_effects']) && is_array($postedStage['tick_effects']) ? array_values($postedStage['tick_effects']) : [], '_posted_outcomes' => isset($postedStage['outcomes']) && is_array($postedStage['outcomes']) ? array_values($postedStage['outcomes']) : [], ]; } if ($stages === []) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Timed Status progression needs at least one valid Stage.', ]; } $storylets = gameplayStatusLifecycleStorylets( $pdo, $gameId ); /* WAYFARER_STATUS_OWNER_EFFECTS_AUTHORING_V92 */ $subjectTraitCatalog = gameplayStatusLifecycleOwnerTraitCatalog( $pdo, $gameId, $appliesTo ); foreach ($stages as $stageIndex => &$stage) { $stageNumber = $stageIndex + 1; $entryEffectResult = gameplayStatusLifecycleBuildEffects( $stage['_posted_entry_effects'] ?? [], $subjectTraitCatalog, false, 'Status Stage ' . $stageNumber . ' On Enter Effects' ); if ($entryEffectResult['error'] !== '') { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => $entryEffectResult['error'], ]; } $tickEffectResult = gameplayStatusLifecycleBuildEffects( $stage['_posted_tick_effects'] ?? [], $subjectTraitCatalog, true, 'Status Stage ' . $stageNumber . ' While in Stage Effects' ); if ($tickEffectResult['error'] !== '') { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => $tickEffectResult['error'], ]; } $stage['entry_effects'] = $entryEffectResult['effects']; $stage['tick_effects'] = $tickEffectResult['effects']; $postedOutcomes = $stage['_posted_outcomes']; unset( $stage['_posted_entry_effects'], $stage['_posted_tick_effects'], $stage['_posted_outcomes'] ); if ($postedOutcomes === []) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' needs at least one Outcome.', ]; } $outcomes = []; $outcomeKeys = []; $chanceTotal = 0; foreach ($postedOutcomes as $outcomeIndex => $postedOutcome) { if (!is_array($postedOutcome)) { continue; } $outcomeNumber = $outcomeIndex + 1; $key = gameplayStatusLifecycleNormalizeKey( (string)($postedOutcome['key'] ?? '') ); if ( $key === '' || preg_match('/^[a-z][a-z0-9_]{0,47}$/', $key) !== 1 ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' has an invalid internal key. Remove and re-add that Outcome.', ]; } if (isset($outcomeKeys[$key])) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' has duplicate Outcome keys.', ]; } $label = trim((string)($postedOutcome['label'] ?? '')); if ($label === '') { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' needs an Outcome Name.', ]; } $chance = $postedOutcome['chance'] ?? ''; if ( filter_var($chance, FILTER_VALIDATE_INT) === false || (int)$chance < 1 || (int)$chance > 100 ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' Chance must be a whole number from 1 to 100.', ]; } $chance = (int)$chance; $chanceTotal += $chance; $result = strtolower(trim((string)($postedOutcome['result'] ?? 'inactive'))); if ( !in_array($result, ['inactive', 'stay'], true) && preg_match('/^stage:([a-z][a-z0-9_]{0,47})$/', $result, $match) !== 1 ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' has an invalid Result.', ]; } if ( strpos($result, 'stage:') === 0 && !isset($stageKeys[substr($result, 6)]) ) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' points to a Stage that no longer exists.', ]; } $presentation = strtolower(trim((string)($postedOutcome['presentation'] ?? 'silent'))); if (!in_array($presentation, ['silent', 'storylet'], true)) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' has an invalid Presentation Mode.', ]; } $storyletId = 0; if ($presentation === 'storylet') { $storyletId = (int)($postedOutcome['storylet_id'] ?? 0); if ($storyletId <= 0 || !isset($storylets[$storyletId])) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' must select a root Storylet from this Game.', ]; } } $outcomeEffectResult = gameplayStatusLifecycleBuildEffects( $postedOutcome['effects'] ?? [], $subjectTraitCatalog, false, 'Status Stage ' . $stageNumber . ', Outcome ' . $outcomeNumber . ' Effects' ); if ($outcomeEffectResult['error'] !== '') { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => $outcomeEffectResult['error'], ]; } $outcomeKeys[$key] = true; $outcomes[] = [ 'key' => $key, 'label' => $label, 'chance' => $chance, 'result' => $result, 'effects' => $outcomeEffectResult['effects'], 'presentation' => $presentation, 'storylet_id' => $presentation === 'storylet' ? $storyletId : null, ]; } if ($outcomes === []) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' needs at least one valid Outcome.', ]; } if ($chanceTotal !== 100) { return [ 'lifecycle' => null, 'editor' => $postedLifecycle, 'error' => 'Status Stage ' . $stageNumber . ' Outcome chances must total exactly 100%. Current total: ' . $chanceTotal . '%.', ]; } $stage['outcomes'] = $outcomes; } unset($stage); $lifecycle = [ 'mode' => 'progression', 'stages' => $stages, ]; return [ 'lifecycle' => $lifecycle, 'editor' => $lifecycle, 'error' => '', ]; } ================================================================================ FILE: includes/gameplay/storylet-bindings.php ================================================================================ 'Random Party Member', 'random_possessed_item' => 'Random Possessed Item', ]; } function gameplayStoryletBindingNormalizeKey( string $value ): string { return strtolower( trim($value) ); } function gameplayStoryletBindingNormalizeTraitName( string $value ): string { $value = trim( preg_replace( '/\s+/u', ' ', $value ) ?? $value ); if (function_exists('mb_strtolower')) { return mb_strtolower( $value, 'UTF-8' ); } return strtolower($value); } function gameplayStoryletBindingNamedTraitId( string $traitName ): string { return 'binding_name_' . bin2hex( gameplayStoryletBindingNormalizeTraitName( $traitName ) ); } function gameplayBuildStoryletBindingsFromPost( $postedBindings ): array { $bindings = []; $error = ''; if ($postedBindings === null || $postedBindings === '') { return [ 'bindings' => [], 'error' => '', ]; } if (!is_array($postedBindings)) { return [ 'bindings' => [], 'error' => 'Storylet Bindings must be a list.', ]; } $selectors = gameplayStoryletBindingSelectorOptions(); $seen = []; foreach ( array_values($postedBindings) as $index => $row ) { if (!is_array($row)) { continue; } $key = gameplayStoryletBindingNormalizeKey( (string)( $row['key'] ?? '' ) ); $label = trim( (string)( $row['label'] ?? '' ) ); $selector = strtolower( trim( (string)( $row['selector'] ?? '' ) ) ); /* * Ignore a completely blank newly-added row. */ if ( $key === '' && $label === '' && $selector === '' ) { continue; } $rowNumber = $index + 1; if ( $key === '' || preg_match( '/^[a-z][a-z0-9_]{0,31}$/', $key ) !== 1 ) { $error = 'Binding ' . $rowNumber . ' needs a Key beginning with a letter and containing only lowercase letters, numbers, and underscores (maximum 32 characters).'; break; } if (isset($seen[$key])) { $error = 'Binding Keys must be unique. "' . $key . '" is used more than once.'; break; } if ($label === '') { $error = 'Binding ' . $rowNumber . ' needs a Binding Name.'; break; } $labelLength = function_exists('mb_strlen') ? mb_strlen( $label, 'UTF-8' ) : strlen($label); if ($labelLength > 80) { $error = 'Binding ' . $rowNumber . ' Binding Name must be 80 characters or fewer.'; break; } if (!isset($selectors[$selector])) { $error = 'Binding ' . $rowNumber . ' must use a valid Selection Rule.'; break; } $seen[$key] = true; $bindings[] = [ 'key' => $key, 'label' => $label, 'selector' => $selector, ]; } return [ 'bindings' => $bindings, 'error' => $error, ]; } function gameplayStoryletBindingTraitCatalog( PDO $pdo, int $gameId, string $entityType ): array { $entityType = strtolower( trim($entityType) ); if ( $gameId <= 0 || !in_array( $entityType, ['character', 'item'], true ) ) { return []; } $stmt = $pdo->prepare( " SELECT id, config FROM wf_entities WHERE game_id = ? AND status = 'active' AND LOWER(entity_type) = ? ORDER BY id " ); $stmt->execute([ $gameId, $entityType, ]); $catalog = []; foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $config = json_decode( (string)( $row['config'] ?? '' ), true ); if (!is_array($config)) { $config = []; } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || ( array_key_exists( 'active', $trait ) && !$trait['active'] ) || !empty($trait['system']) ) { continue; } $name = trim( (string)( $trait['name'] ?? '' ) ); if ($name === '') { continue; } $type = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); if ($type === 'binary') { $type = 'boolean'; } if ( !in_array( $type, ['number', 'boolean'], true ) ) { continue; } $normalizedName = gameplayStoryletBindingNormalizeTraitName( $name ); if ($normalizedName === '') { continue; } if (!isset($catalog[$normalizedName])) { $catalog[$normalizedName] = [ 'name' => $name, 'data_type' => $type, 'conflict' => false, ]; continue; } if ( $catalog[$normalizedName]['data_type'] !== $type ) { /* * A random binding cannot safely target one logical Trait name * when different Entities define it with incompatible types. */ $catalog[$normalizedName]['conflict'] = true; } } } foreach ($catalog as $name => $definition) { if (!empty($definition['conflict'])) { unset($catalog[$name]); } } uasort( $catalog, static function ( array $a, array $b ): int { return strcasecmp( (string)$a['name'], (string)$b['name'] ); } ); return $catalog; } function gameplayStoryletBindingEffectTargets( PDO $pdo, int $gameId, array $bindings ): array { $targets = []; /* WAYFARER_PARTY_BINDINGS_V85 */ $partyTraits = function_exists( 'gameplayPartyTraitCatalog' ) ? gameplayPartyTraitCatalog( $pdo, $gameId ) : gameplayStoryletBindingTraitCatalog( $pdo, $gameId, 'character' ); $itemTraits = gameplayStoryletBindingTraitCatalog( $pdo, $gameId, 'item' ); foreach ($bindings as $binding) { if (!is_array($binding)) { continue; } $key = gameplayStoryletBindingNormalizeKey( (string)( $binding['key'] ?? '' ) ); $label = trim( (string)( $binding['label'] ?? $key ) ); $selector = strtolower( trim( (string)( $binding['selector'] ?? '' ) ) ); if ( $key === '' || preg_match( '/^[a-z][a-z0-9_]{0,31}$/', $key ) !== 1 ) { continue; } $scope = 'binding:' . $key; $group = 'Binding — ' . ( $label !== '' ? $label : $key ); if ($selector === 'random_party_character') { $targetId = 'sys_character_party_member'; $targetKey = $scope . ':trait:' . $targetId; $targets[$targetKey] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $targetId, 'target_system_key' => 'party_member', 'trait_type' => 'boolean', 'data_type' => 'boolean', 'semantic_type' => 'storylet_binding_trait', 'condition_operators' => [ 'equals', 'not_equals', ], 'effect_operations' => [ 'set', ], 'supports_random' => false, 'group' => $group, 'label' => 'Party Membership', 'helper' => 'Random Party Member', /* Legacy Entity-only property: keep saved Effects editable. */ 'effect_hidden' => true, 'hierarchy' => [ [ 'key' => 'storylet_binding', 'label' => 'Storylet Binding', ], [ 'key' => $scope, 'label' => $label !== '' ? $label : $key, ], [ 'key' => 'party_membership', 'label' => 'Party Membership', ], ], ]; foreach ( $partyTraits as $trait ) { $namedTargetId = gameplayStoryletBindingNamedTraitId( (string)$trait['name'] ); $targetKey = $scope . ':trait:' . $namedTargetId; $dataType = (string)$trait['data_type']; $targets[$targetKey] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $namedTargetId, 'trait_type' => $dataType, 'data_type' => $dataType, 'semantic_type' => 'storylet_binding_named_trait', 'condition_operators' => $dataType === 'boolean' ? [ 'equals', 'not_equals', ] : wayfarerConditionOperators(), 'effect_operations' => $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ], 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => (string)$trait['name'], 'helper' => 'Random Party Member', 'hierarchy' => [ [ 'key' => 'storylet_binding', 'label' => 'Storylet Binding', ], [ 'key' => $scope, 'label' => $label !== '' ? $label : $key, ], [ 'key' => $namedTargetId, 'label' => (string)$trait['name'], ], ], ]; } /* WAYFARER_STATUS_BINDING_TARGETS_V87 */ if (function_exists('gameplayStatusDefinitions')) { foreach ( gameplayStatusDefinitions( $pdo, $gameId, true ) as $statusDefinition ) { $statusId = (int)($statusDefinition['id'] ?? 0); $statusName = trim((string)($statusDefinition['name'] ?? '')); if ( $statusId <= 0 || $statusName === '' || (function_exists('gameplayStatusAppliesTo') && !gameplayStatusAppliesTo($statusDefinition, 'character')) ) { continue; } $statusTargetKey = $scope . ':status:' . $statusId; $targets[$statusTargetKey] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'status', 'target_id' => (string)$statusId, 'trait_type' => 'boolean', 'data_type' => 'boolean', 'semantic_type' => 'status', 'condition_operators' => [ 'equals', 'not_equals', ], 'effect_operations' => [ 'set', ], 'supports_random' => false, 'value_options' => [ '0' => 'Inactive', '1' => 'Active', ], 'group' => $group, 'label' => 'Status — ' . $statusName, 'helper' => 'Persistent Status on the selected Party Member', 'hierarchy' => [ [ 'key' => 'storylet_binding', 'label' => 'Storylet Binding', ], [ 'key' => $scope, 'label' => $label !== '' ? $label : $key, ], [ 'key' => 'status:' . $statusId, 'label' => 'Status — ' . $statusName, ], ], ]; } } continue; } if ($selector === 'random_possessed_item') { $targetId = 'sys_item_quantity'; $targetKey = $scope . ':trait:' . $targetId; $targets[$targetKey] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $targetId, 'target_system_key' => 'quantity', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'storylet_binding_trait', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => [ 'set', 'add', 'subtract', ], 'supports_random' => true, 'group' => $group, 'label' => 'Quantity', 'helper' => 'Random Possessed Item', 'hierarchy' => [ [ 'key' => 'storylet_binding', 'label' => 'Storylet Binding', ], [ 'key' => $scope, 'label' => $label !== '' ? $label : $key, ], [ 'key' => 'quantity', 'label' => 'Quantity', ], ], ]; foreach ( $itemTraits as $trait ) { $namedTargetId = gameplayStoryletBindingNamedTraitId( (string)$trait['name'] ); $targetKey = $scope . ':trait:' . $namedTargetId; $dataType = (string)$trait['data_type']; $targets[$targetKey] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $namedTargetId, 'trait_type' => $dataType, 'data_type' => $dataType, 'semantic_type' => 'storylet_binding_named_trait', 'condition_operators' => $dataType === 'boolean' ? [ 'equals', 'not_equals', ] : wayfarerConditionOperators(), 'effect_operations' => $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ], 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => (string)$trait['name'], 'helper' => 'Random Possessed Item', 'hierarchy' => [ [ 'key' => 'storylet_binding', 'label' => 'Storylet Binding', ], [ 'key' => $scope, 'label' => $label !== '' ? $label : $key, ], [ 'key' => $namedTargetId, 'label' => (string)$trait['name'], ], ], ]; } /* WAYFARER_STATUS_OWNER_BINDING_TARGETS_V92 */ if (function_exists('gameplayStatusDefinitions')) { foreach ( gameplayStatusDefinitions( $pdo, $gameId, true ) as $statusDefinition ) { $statusId = (int)($statusDefinition['id'] ?? 0); $statusName = trim((string)($statusDefinition['name'] ?? '')); if ( $statusId <= 0 || $statusName === '' || (function_exists('gameplayStatusAppliesTo') && !gameplayStatusAppliesTo($statusDefinition, 'item')) ) { continue; } $statusTargetKey = $scope . ':status:' . $statusId; $targets[$statusTargetKey] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'status', 'target_id' => (string)$statusId, 'trait_type' => 'boolean', 'data_type' => 'boolean', 'semantic_type' => 'status', 'condition_operators' => [ 'equals', 'not_equals', ], 'effect_operations' => [ 'set', ], 'supports_random' => false, 'value_options' => [ '0' => 'Inactive', '1' => 'Active', ], 'group' => $group, 'label' => 'Status — ' . $statusName, 'helper' => 'Persistent Status on the selected Item', 'hierarchy' => [ [ 'key' => 'storylet_binding', 'label' => 'Storylet Binding', ], [ 'key' => $scope, 'label' => $label !== '' ? $label : $key, ], [ 'key' => 'status:' . $statusId, 'label' => 'Status — ' . $statusName, ], ], ]; } } } } return $targets; } ================================================================================ FILE: includes/gameplay/tick-effects-editor.php ================================================================================ Select target...'; if ($gameplayTargetCatalog) { $selectedScope = (string)( $tickEffect['target_scope'] ?? '' ); $selectedType = (string)( $tickEffect['target_type'] ?? '' ); $selectedId = (string)( $tickEffect['target_id'] ?? '' ); $selectedKey = $selectedScope . ':' . $selectedType . ':' . $selectedId; /* * WAYFARER_POSTED_TICK_TARGET_PRESERVE_V67 * * Failed POSTs are intentionally re-rendered from their submitted * rows. Submitted rows use the form field "target" rather than the * canonical stored target_scope / target_type / target_id fields. */ $postedSelectedKey = trim( (string)( $tickEffect['target'] ?? '' ) ); if ( $postedSelectedKey !== '' && isset( $gameplayTargetCatalog[ $postedSelectedKey ] ) ) { $selectedKey = $postedSelectedKey; } /* * WAYFARER_LEGACY_TICK_TARGET_SELECTION_V65 * * Pre-universal-target Tick Effects did not always store * target_type. Infer the canonical catalog key for display so the * existing Effect remains selected instead of silently posting the * blank first option on the next Region save. */ if ( $selectedScope !== '' && $selectedType === '' && $selectedId !== '' ) { foreach ( $gameplayTargetCatalog as $catalogKey => $catalogTarget ) { if (!is_array($catalogTarget)) { continue; } if ( (string)( $catalogTarget[ 'target_scope' ] ?? '' ) === $selectedScope && (string)( $catalogTarget[ 'target_id' ] ?? '' ) === $selectedId ) { $selectedKey = (string)$catalogKey; break; } } } $grouped = []; foreach ( $gameplayTargetCatalog as $value => $target ) { if (!is_array($target)) { continue; } if ( !empty($target['effect_hidden']) && $value !== $selectedKey ) { continue; } $group = (string)( $target['group'] ?? 'Targets' ); if (!isset($grouped[$group])) { $grouped[$group] = []; } $grouped[$group][$value] = $target; } foreach ($grouped as $group => $options) { echo ''; foreach ($options as $value => $target) { echo ''; } echo ''; } return; } if ($gameplayAllowNavigation) { echo ''; echo ''; echo ''; } if ($gameplayGameTraitTargets) { echo ''; foreach ( $gameplayGameTraitTargets as $targetTrait ) { echo ''; } echo ''; } if ( $gameplayScopeKey !== '' && $gameplayScopeTraitTargets ) { echo ''; foreach ( $gameplayScopeTraitTargets as $targetTrait ) { echo ''; } echo ''; } }; $renderBranchResultFields = function ( array $branch, string $name ): void { $operation = gameplayTickEffectBranchOperation( $branch ); $value = gameplayTickEffectBranchValue( $branch ); $valueMode = gameplayTickEffectBranchValueMode( $branch ); $minimum = gameplayTickEffectBranchMin( $branch ); $maximum = gameplayTickEffectBranchMax( $branch ); ?>
data-gameplay-scope="" data-navigation-type="" >

Tick Effects

Each Tick Effect controls one target. Its results are checked from top to bottom. The first matching result wins within that Tick Effect. Separate Tick Effects remain independent.

$tickEffect ): ?>
$branch ): ?>
THEN

================================================================================ FILE: includes/gameplay/travel-modifiers.php ================================================================================ $postedModifier ) { if (!is_array($postedModifier)) { continue; } $id = trim( (string)( $postedModifier['id'] ?? '' ) ); if ($id === '') { $id = wayfarerTravelModifierMakeId(); } $name = trim( (string)( $postedModifier['name'] ?? '' ) ); $operation = strtolower( trim( (string)( $postedModifier['operation'] ?? 'add' ) ) ); $valueRaw = trim( (string)( $postedModifier['value'] ?? '' ) ); $postedConditions = $postedModifier['conditions'] ?? null; $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, $allowedTargets ); $conditions = $conditionResult['node']; /* * A completely blank newly-added row is ignored. An empty Conditions * group is valid and means the modifier always applies. */ if ( $name === '' && $valueRaw === '' && $conditions === null && $conditionResult['error'] === '' ) { continue; } $editorValue = is_numeric($valueRaw) ? wayfarerTravelModifierNormalizeNumber( (float)$valueRaw ) : $valueRaw; $editorModifier = [ 'id' => $id, 'name' => $name, 'operation' => $operation, 'value' => $editorValue, 'conditions' => $conditions, ]; $editorModifiers[] = $editorModifier; $label = $name !== '' ? 'Travel Modifier "' . $name . '"' : 'Travel Modifier ' . ($index + 1); if ( $conditionResult['error'] !== '' && $firstError === '' ) { $firstError = $label . ': ' . $conditionResult['error']; continue; } if ( $name === '' && $firstError === '' ) { $firstError = 'Each Travel Modifier must have a Name.'; continue; } if ( !in_array( $operation, ['add', 'subtract'], true ) && $firstError === '' ) { $firstError = $label . ' must use Add or Subtract.'; continue; } if ( !is_numeric($valueRaw) || (float)$valueRaw <= 0 ) { if ($firstError === '') { $firstError = $label . ' must use an Amount greater than zero.'; } continue; } $value = wayfarerTravelModifierNormalizeNumber( (float)$valueRaw ); $modifiers[] = [ 'id' => $id, 'name' => $name, 'operation' => $operation, 'value' => $value, 'conditions' => $conditions, ]; } return [ 'modifiers' => $firstError === '' ? $modifiers : [], 'editor_modifiers' => $editorModifiers, 'error' => $firstError, ]; } function wayfarerRenderGameTravelModifiersEditor( array $modifiers, array $conditionTargets ): void { $modifiers = array_values( $modifiers ); ?>

Travel

Global travel rules follow the party between Regions. Matching modifiers adjust a Region's dedicated Base Distance Per Tick whenever a normal travel tick moves the player.

Example: if a Region moves 10 units per tick and a Car adds 5 while its Conditions are met, that travel tick moves 15 units. These modifiers are reevaluated every travel tick and do not produce normal Effect feedback.

$modifier ): ?>


Applied to the current Region's Base Distance Per Tick.

Apply when

No Conditions means this modifier always applies during dedicated Linear Travel.

Untitled Document ================================================================================ FILE: includes/traits/editor.php ================================================================================ 'traits', 'editor_id' => 'traits', 'allowed_surfaces' => [ 'inventory', 'status', ], 'intro' => 'Each authored trait can be shown in Inventory, Status, both, or neither. ' . 'These settings affect presentation only; hidden traits remain available ' . 'to gameplay logic.', ] ); ================================================================================ FILE: includes/traits/functions.php ================================================================================ [...], * 'error' => '' * ] */ function buildTraitsFromPost( array $postedTraits, array $allowedDisplaySurfaces = ['inventory', 'status'] ): array { $traits = []; $error = ''; foreach ($postedTraits as $postedTrait) { $name = trim( $postedTrait['name'] ?? '' ); $type = $postedTrait['type'] ?? 'number'; /* * WAYFARER_TRAIT_PLAYER_DISPLAY_V41 * * Presentation metadata is independent from runtime trait state. */ $postedDisplaySurfaces = $postedTrait['display_surfaces'] ?? []; if (!is_array($postedDisplaySurfaces)) { $postedDisplaySurfaces = []; } $displaySurfaces = []; $allowedDisplaySurfaces = array_values( array_intersect( ['inventory', 'status'], $allowedDisplaySurfaces ) ); foreach ($postedDisplaySurfaces as $surface) { $surface = (string)$surface; if ( in_array( $surface, $allowedDisplaySurfaces, true ) && !in_array( $surface, $displaySurfaces, true ) ) { $displaySurfaces[] = $surface; } } /* * WAYFARER_STATUS_TRAIT_DISPLAY_V86 * * Presentation metadata is stored with the Trait definition. Existing * Traits that do not have this field remain Number/Value by default. */ $statusDisplay = strtolower( trim( (string)( $postedTrait['status_display'] ?? 'number' ) ) ); if ( !in_array( $statusDisplay, ['number', 'bar', 'both'], true ) ) { $error = $name !== '' ? $name . ': invalid Status display mode.' : 'Invalid Status display mode.'; break; } // Ignore completely blank rows. if ($name === '') { continue; } // Validate type. if (!in_array( $type, ['number', 'boolean'], true )) { $error = 'Invalid trait type.'; break; } // Preserve an existing ID. // Generate one only for a new trait. $traitId = trim( $postedTrait['id'] ?? '' ); if ($traitId === '') { $traitId = 'tr_' . bin2hex(random_bytes(8)); } $trait = [ 'id' => $traitId, 'name' => $name, 'type' => $type, 'active' => ($postedTrait['active'] ?? '1') === '1', 'notes' => trim($postedTrait['notes'] ?? ''), 'display' => [ 'surfaces' => $displaySurfaces, 'status_mode' => $statusDisplay, ] ]; // Boolean if ($type === 'boolean') { /* * Bars are only meaningful for bounded numeric Traits. * If an old/cached form submits another mode for a Boolean, retain * the Trait and normalize its presentation to Number/Value. */ $trait['display']['status_mode'] = 'number'; $trait['initial'] = ($postedTrait['initial_boolean'] ?? '0') === '1'; } // Number else { $min = trim( $postedTrait['min'] ?? '' ); $max = trim( $postedTrait['max'] ?? '' ); if ($min !== '' && !is_numeric($min)) { $error = $name . ': minimum must be a number.'; break; } if ($max !== '' && !is_numeric($max)) { $error = $name . ': maximum must be a number.'; break; } $minValue = $min === '' ? null : (float)$min; $maxValue = $max === '' ? null : (float)$max; if ( $minValue !== null && $maxValue !== null && $minValue > $maxValue ) { $error = $name . ': minimum cannot be greater than maximum.'; break; } if ( in_array( $statusDisplay, ['bar', 'both'], true ) && ( $minValue === null || $maxValue === null || $maxValue <= $minValue ) ) { $error = $name . ': Bar Status display requires both Minimum and Maximum, ' . 'with Maximum greater than Minimum.'; break; } $initialMode = strtolower( trim( (string)( $postedTrait['initial_mode'] ?? 'fixed' ) ) ); if ( !in_array( $initialMode, ['fixed', 'random'], true ) ) { $error = $name . ': invalid initial value mode.'; break; } $trait['min'] = $minValue; $trait['max'] = $maxValue; $trait['initial_mode'] = $initialMode; if ($initialMode === 'random') { $initialMin = trim( (string)( $postedTrait['initial_min'] ?? '' ) ); $initialMax = trim( (string)( $postedTrait['initial_max'] ?? '' ) ); if ( $initialMin === '' || !is_numeric($initialMin) ) { $error = $name . ': random initial minimum must be a number.'; break; } if ( $initialMax === '' || !is_numeric($initialMax) ) { $error = $name . ': random initial maximum must be a number.'; break; } $initialMinValue = (float)$initialMin; $initialMaxValue = (float)$initialMax; if ($initialMinValue > $initialMaxValue) { $error = $name . ': random initial minimum cannot be greater than maximum.'; break; } if ( $minValue !== null && $initialMinValue < $minValue ) { $error = $name . ': random initial minimum cannot be less than the Trait minimum.'; break; } if ( $maxValue !== null && $initialMaxValue > $maxValue ) { $error = $name . ': random initial maximum cannot be greater than the Trait maximum.'; break; } $trait['initial_min'] = $initialMinValue; $trait['initial_max'] = $initialMaxValue; } else { $initial = trim( (string)( $postedTrait['initial_number'] ?? '' ) ); if ( $initial !== '' && !is_numeric($initial) ) { $error = $name . ': initial value must be a number.'; break; } $initialValue = $initial === '' ? 0 : (float)$initial; if ( $minValue !== null && $initialValue < $minValue ) { $error = $name . ': initial value cannot be less than minimum.'; break; } if ( $maxValue !== null && $initialValue > $maxValue ) { $error = $name . ': initial value cannot be greater than maximum.'; break; } $trait['initial'] = $initialValue; } } $traits[] = $trait; } return [ 'traits' => $traits, 'error' => $error ]; } ================================================================================ FILE: includes/traits/render.php ================================================================================

$trait): ?> (float)$trait['min']; if ( !$traitStatusBarAvailable && $traitStatusMode !== 'number' ) { $traitStatusMode = 'number'; } ?>
Name Type Minimum Maximum Initial Value Status
— —
Show to Player: Bars require a Number Trait with both Minimum and Maximum. True / False Traits always display their value. Controls where this trait is displayed to the player. Unchecked traits remain available to gameplay logic.

false, 'error' => 'POST required.', ] ); } if ( session_status() !== PHP_SESSION_ACTIVE ) { session_start(); } require_once __DIR__ . '/../../config/database.php'; require_once __DIR__ . '/../includes/runtime.php'; try { $rawInput = file_get_contents( 'php://input' ); $input = json_decode( $rawInput ?: '{}', true ); if (!is_array($input)) { wayfarerPresentationRespond( 400, [ 'ok' => false, 'error' => 'Invalid JSON request.', ] ); } $csrfToken = isset($input['csrf_token']) ? (string)$input['csrf_token'] : ''; $sessionToken = isset( $_SESSION[ 'wayfarer_tick_csrf' ] ) ? (string)$_SESSION[ 'wayfarer_tick_csrf' ] : ''; if ( $csrfToken === '' || $sessionToken === '' || !hash_equals( $sessionToken, $csrfToken ) ) { wayfarerPresentationRespond( 403, [ 'ok' => false, 'error' => 'Invalid runtime token.', ] ); } $requestedId = isset( $input['game_user_id'] ) ? (int)$input[ 'game_user_id' ] : null; $gameUserId = wayfarerRuntimeResolveGameUserId( $pdo, $requestedId ); $expectedType = trim( (string)( $input['type'] ?? '' ) ); $expectedId = (int)( $input['id'] ?? 0 ); $result = wayfarerRuntimeDismissPresentation( $pdo, $gameUserId, $expectedType, $expectedId ); wayfarerPresentationRespond( 200, [ 'ok' => true, 'game_user_id' => $result[ 'game_user_id' ], 'state' => $result['state'], /* WAYFARER_PLAYER_CONTEXT_API_V46_1 */ 'context' => $result['context'] ?? null, 'dismissed' => $result['dismissed'], /* WAYFARER_STORYLET_ACTIONS_AFTER_PRESENTATION_V59 */ 'actions' => $result['actions'] ?? [], ] ); } catch (Throwable $exception) { wayfarerPresentationRespond( 500, [ 'ok' => false, 'error' => $exception->getMessage(), ] ); } ================================================================================ FILE: play/api/restart.php ================================================================================ false, 'error' => 'POST required.', ] ); } if ( session_status() !== PHP_SESSION_ACTIVE ) { session_start(); } require_once __DIR__ . '/../../config/database.php'; require_once __DIR__ . '/../includes/runtime.php'; try { $rawInput = file_get_contents( 'php://input' ); $input = json_decode( $rawInput ?: '{}', true ); if (!is_array($input)) { wayfarerRestartRespond( 400, [ 'ok' => false, 'error' => 'Invalid JSON request.', ] ); } $csrfToken = isset($input['csrf_token']) ? (string)$input['csrf_token'] : ''; $sessionToken = isset( $_SESSION['wayfarer_tick_csrf'] ) ? (string)$_SESSION['wayfarer_tick_csrf'] : ''; if ( $csrfToken === '' || $sessionToken === '' || !hash_equals( $sessionToken, $csrfToken ) ) { wayfarerRestartRespond( 403, [ 'ok' => false, 'error' => 'Invalid runtime token.', ] ); } $requestedId = isset($input['game_user_id']) ? (int)$input['game_user_id'] : null; $gameUserId = wayfarerRuntimeResolveGameUserId( $pdo, $requestedId ); $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ?? null ); $ending = $state['runtime']['ending'] ?? null; if ( !is_array($ending) || empty($ending['id']) ) { throw new RuntimeException( 'This playthrough has not ended.' ); } $update = $pdo->prepare( " UPDATE wf_game_users SET state = NULL WHERE id = ? " ); $update->execute([ $gameUserId ]); $pdo->commit(); wayfarerRestartRespond( 200, [ 'ok' => true, 'game_user_id' => $gameUserId, 'restarted' => true, ] ); } catch (Throwable $exception) { if ( isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction() ) { $pdo->rollBack(); } wayfarerRestartRespond( 409, [ 'ok' => false, 'error' => $exception->getMessage(), ] ); } ================================================================================ FILE: play/api/storylet.php ================================================================================ false, 'error' => 'POST required.', ] ); } if ( session_status() !== PHP_SESSION_ACTIVE ) { session_start(); } require_once __DIR__ . '/../../config/database.php'; require_once __DIR__ . '/../includes/runtime.php'; try { $rawInput = file_get_contents( 'php://input' ); $input = json_decode( $rawInput ?: '{}', true ); if (!is_array($input)) { wayfarerStoryletRespond( 400, [ 'ok' => false, 'error' => 'Invalid JSON request.', ] ); } $csrfToken = isset($input['csrf_token']) ? (string)$input['csrf_token'] : ''; $sessionToken = isset( $_SESSION['wayfarer_tick_csrf'] ) ? (string)$_SESSION['wayfarer_tick_csrf'] : ''; if ( $csrfToken === '' || $sessionToken === '' || !hash_equals( $sessionToken, $csrfToken ) ) { wayfarerStoryletRespond( 403, [ 'ok' => false, 'error' => 'Invalid runtime token.', ] ); } $requestedId = isset($input['game_user_id']) ? (int)$input['game_user_id'] : null; $gameUserId = wayfarerRuntimeResolveGameUserId( $pdo, $requestedId ); $storyletId = (int)( $input['storylet_id'] ?? 0 ); if ($storyletId <= 0) { wayfarerStoryletRespond( 400, [ 'ok' => false, 'error' => 'A Storylet ID is required.', ] ); } $entryMode = trim( (string)( $input['entry_mode'] ?? 'action' ) ); $parentStoryletId = (int)( $input['parent_storylet_id'] ?? 0 ); $result = wayfarerRuntimeEnterStorylet( $pdo, $gameUserId, $storyletId, $entryMode, $parentStoryletId ); wayfarerStoryletRespond( 200, [ 'ok' => true, 'game_user_id' => $result['game_user_id'], 'state' => $result['state'], 'context' => $result['context'] ?? null, 'presentation' => $result['presentation'] ?? null, 'ending' => $result['ending'] ?? null, 'occurrence' => $result['occurrence'] ?? null, 'actions' => $result['actions'] ?? [], ] ); } catch (Throwable $exception) { wayfarerStoryletRespond( 409, [ 'ok' => false, 'error' => $exception->getMessage(), ] ); } ================================================================================ FILE: play/api/tick.php ================================================================================ false, 'error' => 'POST required.', ]); exit; } if (session_status() !== PHP_SESSION_ACTIVE) { session_start(); } require_once __DIR__ . '/../../config/database.php'; require_once __DIR__ . '/../includes/runtime.php'; function wayfarerTickRespond( int $status, array $payload ): void { http_response_code($status); echo json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); exit; } try { $rawInput = file_get_contents( 'php://input' ); $input = json_decode( $rawInput ?: '{}', true ); if (!is_array($input)) { wayfarerTickRespond( 400, [ 'ok' => false, 'error' => 'Invalid JSON request.', ] ); } $csrfToken = isset($input['csrf_token']) ? (string)$input['csrf_token'] : ''; $sessionToken = isset($_SESSION['wayfarer_tick_csrf']) ? (string)$_SESSION['wayfarer_tick_csrf'] : ''; if ( $csrfToken === '' || $sessionToken === '' || !hash_equals( $sessionToken, $csrfToken ) ) { wayfarerTickRespond( 403, [ 'ok' => false, 'error' => 'Invalid tick token.', ] ); } $requestedId = isset($input['game_user_id']) ? (int)$input['game_user_id'] : null; /* WAYFARER_BIDIRECTIONAL_TRAVEL_API_V71 */ $requestedTravelDirection = null; if ( array_key_exists( 'travel_direction', $input ) ) { if (!is_numeric($input['travel_direction'])) { wayfarerTickRespond( 400, [ 'ok' => false, 'error' => 'Travel direction must be -1 or 1.', ] ); } $requestedTravelDirection = (int)$input['travel_direction']; if ( !in_array( $requestedTravelDirection, [-1, 1], true ) ) { wayfarerTickRespond( 400, [ 'ok' => false, 'error' => 'Travel direction must be -1 or 1.', ] ); } } $gameUserId = wayfarerRuntimeResolveGameUserId( $pdo, $requestedId ); $result = wayfarerRuntimeAdvanceTick( $pdo, $gameUserId, $requestedTravelDirection ); wayfarerTickRespond( 200, [ 'ok' => true, 'tick' => $result['tick'], 'game_user_id' => $result['game_user_id'], 'state' => $result['state'], /* WAYFARER_PLAYER_CONTEXT_API_V46_1 */ 'context' => $result['context'] ?? null, /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 * The browser stops automatic advancement immediately when the * runtime reports an interrupting presentation. */ 'pause' => !empty( $result['pause'] ), 'pause_reason' => (string)( $result['pause_reason'] ?? '' ), 'presentation' => $result['presentation'] ?? null, /* WAYFARER_GAME_ENDINGS_TICK_API_V45 */ 'ending' => $result['ending'] ?? null, /* WAYFARER_STORYLET_ACTIONS_TICK_API_V59 */ 'actions' => $result['actions'] ?? [], /* WAYFARER_TRAVEL_TIME_HUD_V61 */ 'travel' => $result['travel'] ?? null, ] ); } catch (Throwable $exception) { wayfarerTickRespond( 500, [ 'ok' => false, 'error' => $exception->getMessage(), ] ); } ================================================================================ FILE: play/assets/play.css ================================================================================ /* |-------------------------------------------------------------------------- | Wayfarer Play Shell v20 |-------------------------------------------------------------------------- */ html, body { height: 100%; } body.wayfarer-play-page { margin: 0; overflow: hidden; background: #eef1f5; color: #20242a; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.45; } .wayfarer-play-page * { box-sizing: border-box; } .wayfarer-play-page hr { display: none; } .wayfarer-play-shell { height: 100vh; display: flex; flex-direction: column; overflow: hidden; } .wayfarer-play-shell-header { flex: 0 0 auto; min-height: 68px; padding: 12px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; background: #ffffff; border-bottom: 1px solid #d8dbe2; } .wayfarer-play-title { min-width: 0; } .wayfarer-play-title h1 { margin: 0; font-size: 1.35rem; line-height: 1.2; } .wayfarer-play-title p { margin: 4px 0 0; color: #667085; font-size: 0.9rem; } .wayfarer-shell-actions { display: flex; align-items: center; gap: 8px; } .wayfarer-shell-button { border: 1px solid #b8bdc7; border-radius: 6px; padding: 7px 10px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 600; } .wayfarer-shell-button:hover { background: #f6f7f9; } .wayfarer-play-main { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 7fr) minmax(330px, 3fr); gap: 12px; padding: 12px; overflow: hidden; } .wayfarer-player-pane, .wayfarer-author-pane { min-width: 0; min-height: 0; background: #ffffff; border: 1px solid #d8dbe2; border-radius: 10px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); } .wayfarer-player-pane { overflow: auto; padding: 22px; } .wayfarer-player-pane > h2:first-child { margin-top: 0; } #wayfarer-player-scene { margin-top: 10px; } #wayfarer-player-scene h2 { margin-top: 0; } .wayfarer-author-pane { display: flex; flex-direction: column; overflow: hidden; } .wayfarer-author-header { flex: 0 0 auto; padding: 14px 16px 10px; border-bottom: 1px solid #e3e6eb; } .wayfarer-author-header h2 { margin: 0; font-size: 1.05rem; } .wayfarer-author-header p { margin: 6px 0 0; font-size: 0.85rem; } .wayfarer-author-header a { color: #2f5ea8; } .wayfarer-author-toolbar { flex: 0 0 auto; padding: 10px 14px; background: #f6f7f9; border-bottom: 1px solid #e3e6eb; } .wayfarer-author-toolbar #wayfarer-tick-test { margin: 0; } .wayfarer-author-toolbar #wayfarer-tick-test > h2 { margin: 0 0 6px; font-size: 0.9rem; } .wayfarer-author-toolbar #wayfarer-tick-test > p { margin: 5px 0; font-size: 0.82rem; } .wayfarer-author-toolbar button { margin-right: 4px; margin-bottom: 2px; padding: 5px 8px; } #wayfarer-tick-message { min-height: 1.2em; } .wayfarer-author-tabs { flex: 0 0 auto; display: flex; gap: 2px; padding: 8px 10px 0; background: #ffffff; border-bottom: 1px solid #d8dbe2; } .wayfarer-author-tab { appearance: none; border: 0; border-bottom: 2px solid transparent; padding: 8px 10px 7px; background: transparent; color: #667085; cursor: pointer; font: inherit; font-size: 0.86rem; font-weight: 600; } .wayfarer-author-tab:hover { color: #20242a; } .wayfarer-author-tab.is-active { color: #2f5ea8; border-bottom-color: #2f5ea8; } .wayfarer-author-content { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 14px 16px 18px; } .wayfarer-author-tab-panel { display: none; } .wayfarer-author-tab-panel.is-active { display: block; } .wayfarer-author-tab-panel > :first-child { margin-top: 0; } .wayfarer-author-tab-panel h3 { margin-top: 0; font-size: 1rem; } .wayfarer-author-tab-panel h4 { margin-bottom: 8px; } .wayfarer-author-tab-panel table { width: 100%; border-collapse: collapse; font-size: 0.84rem; } .wayfarer-author-tab-panel th, .wayfarer-author-tab-panel td { padding: 6px 7px; text-align: left; vertical-align: top; } .wayfarer-author-tab-panel pre { max-width: 100%; overflow: auto; font-size: 0.78rem; } #wayfarer-author-debug-panel details { margin-top: 0 !important; } #wayfarer-author-debug-panel summary { display: none; } #wayfarer-author-debug-panel details:not([open]) > *:not(summary) { display: block; } #wayfarer-author-debug-panel details > pre { display: block; max-height: none !important; margin-top: 0 !important; } .wayfarer-player-meta { margin-bottom: 14px; padding: 10px 12px; border-radius: 7px; background: #f6f7f9; color: #667085; font-size: 0.86rem; } .wayfarer-player-meta h2 { margin: 0 0 4px; color: #20242a; font-size: 0.95rem; } .wayfarer-player-meta p { margin: 0; } .wayfarer-play-shell.author-collapsed .wayfarer-play-main { grid-template-columns: minmax(0, 1fr); } .wayfarer-play-shell.author-collapsed .wayfarer-author-pane { display: none; } @media (max-width: 900px) { body.wayfarer-play-page { overflow: auto; } .wayfarer-play-shell { height: auto; min-height: 100vh; overflow: visible; } .wayfarer-play-main { display: block; overflow: visible; padding: 8px; } .wayfarer-player-pane, .wayfarer-author-pane { overflow: visible; margin-bottom: 10px; } .wayfarer-author-pane { min-height: 480px; } .wayfarer-author-content { overflow: visible; } .wayfarer-play-shell.author-collapsed .wayfarer-author-pane { display: none; } } /* ------------------------------------------------------------------ GAME START SCREEN WAYFARER_PLAYER_START_SCREEN_V36 ------------------------------------------------------------------ */ /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * The start screen no longer implies "no Author Mode." Authors may inspect * the Author panel before initialization. The existing author-collapsed class * controls the one-column layout for normal players and collapsed authors. */ .wayfarer-start-screen { width: min(860px, 100%); margin: 0 auto; } .wayfarer-start-screen-heading { margin-bottom: 24px; } .wayfarer-start-screen-heading h2 { margin: 0 0 12px; font-size: 1.65rem; } .wayfarer-start-introduction { max-width: 72ch; color: #4b5563; line-height: 1.65; } .wayfarer-start-error { margin-bottom: 18px; padding: 10px 12px; border: 1px solid #e4a7ad; border-radius: 7px; background: #fff4f5; color: #8b1e2d; } .wayfarer-start-form { display: grid; gap: 22px; } .wayfarer-start-section, .wayfarer-start-choice { margin: 0; padding: 18px; border: 1px solid #d8dbe2; border-radius: 9px; background: #ffffff; } .wayfarer-start-section h3, .wayfarer-start-choice legend { margin: 0 0 14px; color: #20242a; font-size: 1.05rem; font-weight: 700; } .wayfarer-start-choice legend { padding: 0 6px; } .wayfarer-start-names { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } .wayfarer-start-names label { display: grid; gap: 5px; font-weight: 600; } .wayfarer-start-names input { width: 100%; padding: 9px 10px; border: 1px solid #b8bdc7; border-radius: 6px; font: inherit; } .wayfarer-start-choice { display: grid; gap: 10px; } .wayfarer-start-option { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: start; padding: 12px 13px; border: 1px solid #d8dbe2; border-radius: 7px; cursor: pointer; } .wayfarer-start-option:hover { background: #f6f7f9; } .wayfarer-start-option input { margin-top: 4px; } .wayfarer-start-option-copy { display: grid; gap: 4px; } .wayfarer-start-option-copy > span { color: #667085; font-size: 0.92rem; } .wayfarer-start-actions { display: flex; justify-content: flex-end; } .wayfarer-start-button { padding: 10px 18px; border: 1px solid #244b88; border-radius: 7px; background: #2f5ea8; color: #ffffff; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-start-button:hover { background: #244b88; } @media (max-width: 700px) { .wayfarer-start-screen { width: 100%; } .wayfarer-start-section, .wayfarer-start-choice { padding: 14px; } } /* ------------------------------------------------------------------ PLAYER ACTION SCREEN WAYFARER_ACTION_SCREEN_V39 ------------------------------------------------------------------ */ .wayfarer-action-screen { margin-top: 14px; padding: 18px; border: 1px solid #d8dbe2; border-radius: 10px; background: #ffffff; box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); } .wayfarer-action-screen[hidden], .wayfarer-travel-controls[hidden] { display: none !important; } .wayfarer-action-heading { margin-bottom: 14px; } .wayfarer-action-heading h2 { margin: 0 0 4px; font-size: 1.25rem; } .wayfarer-action-heading p { margin: 0; color: #667085; } .wayfarer-action-list { display: grid; gap: 10px; } .wayfarer-action-button { width: 100%; padding: 12px 14px; border: 1px solid #214b86; border-radius: 8px; background: #245a9b; color: #ffffff; cursor: pointer; font: inherit; font-weight: 700; text-align: left; } .wayfarer-action-button:hover { background: #1e4c84; } .wayfarer-action-button:disabled { cursor: default; opacity: 0.55; } /* ------------------------------------------------------------------ IN-CARD ACTION UTILITY VIEWS WAYFARER_ACTION_UTILITY_VIEWS_V48 ------------------------------------------------------------------ */ .wayfarer-action-utility-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 2px; } .wayfarer-action-utility-button, .wayfarer-action-back-button { padding: 10px 12px; border: 1px solid #b8bdc7; border-radius: 7px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 650; } .wayfarer-action-utility-button { width: 100%; text-align: center; } .wayfarer-action-utility-button:hover, .wayfarer-action-back-button:hover { border-color: #98a2b3; background: #f6f7f9; } .wayfarer-action-subview[hidden], .wayfarer-action-heading[hidden], .wayfarer-action-list[hidden] { display: none !important; } .wayfarer-action-subview-heading { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 18px; } .wayfarer-action-subview-heading h2 { margin: 0; font-size: 1.25rem; } .wayfarer-action-back-button { flex: 0 0 auto; } .wayfarer-action-subview-body { min-height: 90px; } /* ------------------------------------------------------------------ ENTITY INVENTORY / STATUS PRESENTATION WAYFARER_ENTITY_PRESENTATION_V49 ------------------------------------------------------------------ */ .wayfarer-inventory-list, .wayfarer-party-list { display: grid; gap: 10px; } .wayfarer-inventory-item, .wayfarer-party-member, .wayfarer-status-section { padding: 13px 14px; border: 1px solid #d8dbe2; border-radius: 8px; background: #f8fafc; } .wayfarer-inventory-item-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; } .wayfarer-inventory-quantity { flex: 0 0 auto; color: #667085; font-size: 0.9rem; font-weight: 650; } .wayfarer-status-section + .wayfarer-status-section { margin-top: 12px; } .wayfarer-status-section > h3 { margin: 0 0 10px; font-size: 1rem; } .wayfarer-status-player-name { margin: 0; font-weight: 700; } .wayfarer-party-member { background: #ffffff; } .wayfarer-party-member h4 { margin: 0; font-size: 0.98rem; } /* WAYFARER_PLAYER_TRAITS_STATUS_V52 */ .wayfarer-player-member h4::after { content: "Player"; margin-left: 8px; color: #667085; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; } .wayfarer-status-values { margin: 10px 0 0; } .wayfarer-status-values > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; padding: 6px 0; border-top: 1px solid #e3e6eb; } .wayfarer-status-values dt { color: #4b5563; } .wayfarer-status-values dd { margin: 0; font-weight: 700; text-align: right; } .wayfarer-status-muted { margin: 8px 0 0; color: #667085; font-size: 0.88rem; } /* ------------------------------------------------------------------ ENTITY DETAIL VIEW WAYFARER_ENTITY_DETAIL_VIEW_V50 ------------------------------------------------------------------ */ .wayfarer-entity-open-button { appearance: none; border: 0; padding: 0; background: transparent; color: #2f5ea8; cursor: pointer; font: inherit; font-weight: 750; text-align: left; } .wayfarer-entity-open-button:hover { text-decoration: underline; } .wayfarer-entity-open-button:focus-visible { outline: 2px solid #2f5ea8; outline-offset: 3px; border-radius: 2px; } .wayfarer-status-player-names { display: grid; gap: 4px; } .wayfarer-status-player-names .wayfarer-status-player-name { margin: 0; } .wayfarer-entity-detail-eyebrow { margin: 0 0 3px; color: #667085; font-size: 0.78rem; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; } .wayfarer-entity-detail-layout { display: grid; grid-template-columns: minmax(180px, 280px) minmax(0, 1fr); gap: 22px; align-items: start; } .wayfarer-entity-detail-layout:not(:has(.wayfarer-entity-detail-media)) { grid-template-columns: minmax(0, 1fr); } .wayfarer-entity-detail-media { margin: 0; } .wayfarer-entity-detail-media img { display: block; width: 100%; max-height: 340px; object-fit: contain; border-radius: 8px; background: #f6f7f9; } .wayfarer-entity-detail-description { margin: 0 0 16px; line-height: 1.6; } .wayfarer-entity-detail-copy > :first-child { margin-top: 0; } @media (max-width: 700px) { .wayfarer-entity-detail-layout { grid-template-columns: 1fr; } .wayfarer-entity-detail-media img { max-height: 280px; } } .wayfarer-action-empty { margin: 0; color: #667085; font-size: 0.92rem; } .wayfarer-travel-controls { margin-top: 14px; padding: 12px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid #d8dbe2; border-radius: 10px; background: #ffffff; } .wayfarer-travel-status { display: flex; align-items: center; gap: 9px; font-weight: 700; } /* WAYFARER_TRAVEL_TIME_HUD_V61 */ .wayfarer-travel-copy { min-width: 0; } .wayfarer-travel-primary { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px; } .wayfarer-travel-time { font-weight: 800; } .wayfarer-travel-time-separator { color: #98a2b3; } .wayfarer-travel-next { margin-top: 3px; color: #667085; font-size: 0.88rem; font-weight: 600; } .wayfarer-travel-indicator { width: 9px; height: 9px; border-radius: 999px; background: currentColor; } .wayfarer-travel-pause { padding: 8px 12px; border: 1px solid #aab4c3; border-radius: 7px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 600; } .wayfarer-travel-pause:hover { background: #f6f7f9; } @media (max-width: 620px) { .wayfarer-action-screen { padding: 14px; } .wayfarer-travel-controls { align-items: stretch; flex-direction: column; } .wayfarer-travel-pause { width: 100%; } .wayfarer-action-utility-row { grid-template-columns: 1fr; } } /* ------------------------------------------------------------------ LOCATION / STORY PRESENTATION WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 ------------------------------------------------------------------ */ /* * Scene prose now belongs to Presentation Mode rather than the normal Action * page. The server-rendered scene remains in the DOM temporarily for runtime * compatibility but is not player-facing. */ #wayfarer-player-scene { display: none; } .wayfarer-action-parent-context { margin-bottom: 4px !important; font-size: 0.85rem; font-weight: 600; } /* ------------------------------------------------------------------ STORYLET ACTIONS / BRANCHES WAYFARER_STORYLET_ACTION_RUNTIME_V59 ------------------------------------------------------------------ */ .wayfarer-storylet-action-list { display: grid; gap: 10px; } .wayfarer-storylet-action-list:empty { display: none; } .wayfarer-storylet-branch-list { width: 100%; display: grid; gap: 10px; } .wayfarer-presentation-actions.wayfarer-has-storylet-branches > div:not(.wayfarer-storylet-branch-list) { display: none; } .wayfarer-storylet-branch-prompt { margin: 0 0 4px; color: #b8c0cc; font-weight: 700; } .wayfarer-storylet-branch-button { width: 100%; padding: 12px 14px; border: 1px solid #f8fafc; border-radius: 8px; background: transparent; color: #f8fafc; cursor: pointer; font: inherit; font-weight: 700; text-align: left; } .wayfarer-storylet-branch-button:hover, .wayfarer-storylet-branch-button:focus-visible { background: #f8fafc; color: #15181d; } .wayfarer-storylet-branch-button:disabled { cursor: default; opacity: 0.55; } .wayfarer-presentation-screen { position: fixed; z-index: 10000; inset: 0; overflow-y: auto; padding: clamp(24px, 6vh, 72px) 24px; background: #15181d; color: #f8fafc; } .wayfarer-presentation-screen[hidden] { display: none !important; } .wayfarer-presentation-inner { width: min(1040px, 100%); min-height: calc(100vh - clamp(48px, 12vh, 144px)); margin: 0 auto; display: flex; flex-direction: column; justify-content: center; } .wayfarer-presentation-media { width: 100%; margin: 0 0 26px; overflow: hidden; border-radius: 12px; background: #0d0f12; } .wayfarer-presentation-media img { display: block; width: 100%; max-height: 52vh; object-fit: cover; } .wayfarer-presentation-copy { width: min(760px, 100%); margin: 0 auto; } .wayfarer-presentation-eyebrow { margin: 0 0 8px; color: #b8c0cc; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .wayfarer-presentation-copy h1 { margin: 0 0 18px; color: inherit; font-size: clamp(2rem, 5vw, 3.8rem); line-height: 1.05; } .wayfarer-presentation-description { font-size: clamp(1rem, 2vw, 1.22rem); line-height: 1.7; } .wayfarer-presentation-actions { width: min(760px, 100%); margin: 32px auto 0; display: flex; justify-content: flex-end; } .wayfarer-presentation-continue { min-width: 150px; padding: 11px 18px; border: 1px solid #f8fafc; border-radius: 8px; background: #f8fafc; color: #15181d; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-presentation-continue:hover { background: #e8edf3; } .wayfarer-presentation-continue:focus-visible { outline: 3px solid #ffffff; outline-offset: 4px; } .wayfarer-presentation-key-hint { margin: 10px 0 0; color: #9ea8b6; font-size: 0.78rem; text-align: right; } body.wayfarer-runtime-presentation { overflow: hidden; } @media (max-width: 620px) { .wayfarer-presentation-screen { padding: 20px 14px; } .wayfarer-presentation-inner { min-height: calc(100vh - 40px); } .wayfarer-presentation-actions { justify-content: stretch; } .wayfarer-presentation-continue { width: 100%; } } /* ------------------------------------------------------------------ COMBINED AUTHOR STATE + REGION WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 ------------------------------------------------------------------ */ .wayfarer-author-state-region { margin-top: 20px; padding-top: 14px; border-top: 1px solid #e4e7ec; } .wayfarer-author-state-region > :first-child { margin-top: 0; } /* ------------------------------------------------------------------ TERMINAL GAME ENDING WAYFARER_GAME_ENDING_SCREEN_V45 ------------------------------------------------------------------ */ .wayfarer-ending-screen { position: fixed; z-index: 11000; inset: 0; overflow-y: auto; padding: clamp(24px, 6vh, 72px) 24px; background: #11151a; color: #f8fafc; } .wayfarer-ending-inner { width: min(1040px, 100%); min-height: calc(100vh - clamp(48px, 12vh, 144px)); margin: 0 auto; display: flex; flex-direction: column; justify-content: center; } .wayfarer-ending-media { width: 100%; margin: 0 0 28px; overflow: hidden; border-radius: 12px; background: #080a0d; } .wayfarer-ending-media img { display: block; width: 100%; max-height: 52vh; object-fit: cover; } .wayfarer-ending-copy { width: min(760px, 100%); margin: 0 auto; } .wayfarer-ending-eyebrow { margin: 0 0 8px; color: #aeb8c5; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .wayfarer-ending-copy h1 { margin: 0 0 18px; color: inherit; font-size: clamp(2rem, 5vw, 3.8rem); line-height: 1.05; } .wayfarer-ending-text { font-size: clamp(1rem, 2vw, 1.22rem); line-height: 1.7; } body.wayfarer-game-ended { overflow: hidden; } /* ------------------------------------------------------------------ PRESENTATION TRANSITION + ENDING RESTART WAYFARER_PRESENTATION_TRANSITION_V63 WAYFARER_ENDING_RESTART_V63 ------------------------------------------------------------------ */ body.wayfarer-presentation-transitioning::after { content: ""; position: fixed; z-index: 10500; inset: 0; background: #15181d; pointer-events: all; } .wayfarer-ending-actions { margin-top: 30px; display: flex; justify-content: flex-end; } .wayfarer-ending-restart { min-width: 150px; padding: 11px 18px; border: 1px solid #f8fafc; border-radius: 8px; background: #f8fafc; color: #15181d; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-ending-restart:hover { background: #e8edf3; } .wayfarer-ending-restart:focus-visible { outline: 3px solid #ffffff; outline-offset: 4px; } .wayfarer-ending-restart:disabled { cursor: default; opacity: 0.6; } @media (max-width: 620px) { .wayfarer-ending-actions { justify-content: stretch; } .wayfarer-ending-restart { width: 100%; } } /* ------------------------------------------------------------------ GAME HUD SURFACES WAYFARER_GAME_TRAVEL_HUD_STYLE_V94 WAYFARER_HUD_SURFACES_STYLE_V95 ------------------------------------------------------------------ */ .wayfarer-player-pane { display: flex; flex-direction: column; } .wayfarer-player-hud { flex: 0 0 auto; } .wayfarer-travel-hud { margin-top: 12px; } .wayfarer-action-hud { margin-top: auto; padding-top: 18px; } .wayfarer-player-hud[hidden] { display: none !important; } .wayfarer-hud-components { display: grid; grid-template-columns: repeat( auto-fit, minmax( min(180px, 100%), 1fr ) ); gap: 10px; } .wayfarer-hud-component { min-width: 0; padding: 10px 12px; border: 1px solid #d8dbe2; border-radius: 9px; background: #f8fafc; } .wayfarer-hud-number-component { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } .wayfarer-hud-number { flex: 0 0 auto; font-size: 1rem; font-variant-numeric: tabular-nums; } .wayfarer-hud-progress-component { min-width: 0; } .wayfarer-hud-progress-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 7px; } .wayfarer-hud-label { min-width: 0; overflow: hidden; font-size: 0.84rem; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } .wayfarer-hud-value { flex: 0 0 auto; color: #667085; font-size: 0.82rem; font-variant-numeric: tabular-nums; } .wayfarer-hud-progress-track { height: 8px; overflow: hidden; border-radius: 999px; background: #dfe4ea; } .wayfarer-hud-progress-fill { height: 100%; min-width: 0; border-radius: inherit; background: #2f5ea8; transition: width 180ms ease; } @media (max-width: 620px) { .wayfarer-travel-hud { margin-top: 10px; } .wayfarer-action-hud { padding-top: 14px; } .wayfarer-hud-components { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } } @media (max-width: 420px) { .wayfarer-hud-components { grid-template-columns: 1fr; } } /* ------------------------------------------------------------------ REGION HUD AUTHORING WAYFARER_REGION_HUD_EDITOR_V64 ------------------------------------------------------------------ */ .builder-region-hud { margin: 0; } .builder-region-hud > h3 { margin-top: 0; } .builder-region-hud-component { margin: 14px 0; padding: 14px 16px; border: 1px solid #d8dbe2; border-radius: 8px; } .builder-region-hud-component legend { padding: 0 6px; font-weight: 700; } .builder-region-hud-component select, .builder-region-hud-component input[type="text"] { width: min(560px, 100%); max-width: 100%; } /* ------------------------------------------------------------------ ONE-TIME EFFECT PLAYER FEEDBACK WAYFARER_EFFECT_FEEDBACK_V70 ------------------------------------------------------------------ */ .wayfarer-presentation-changes { margin-top: 18px; padding: 12px 14px; border: 1px solid rgba(31, 41, 55, 0.16); border-radius: 8px; background: rgba(255, 255, 255, 0.68); } .wayfarer-presentation-changes-heading { margin: 0 0 7px; font-size: 0.78rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.68; } .wayfarer-presentation-changes-list { display: grid; gap: 5px; margin: 0; padding: 0; list-style: none; } .wayfarer-presentation-change { margin: 0; font-weight: 700; line-height: 1.35; } .wayfarer-presentation-change.is-gain::before, .wayfarer-presentation-change.is-loss::before { display: inline-block; width: 0; overflow: hidden; content: ''; } ================================================================================ FILE: play/assets/play.js ================================================================================ /* |-------------------------------------------------------------------------- | PLAYER RUNTIME CONTROLLER | WAYFARER_ACTION_SCREEN_V39 | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | Runtime modes currently exposed to the player: | | presentation -> Action -> running navigation -> presentation/Action | | Presentation is persisted by PHP. Automatic tick advancement cannot continue | while a presentation is active. | */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; if (!config) { return; } const body = document.body; const tickValue = document.getElementById( 'wayfarer-tick-value' ); const statusValue = document.getElementById( 'wayfarer-tick-status' ); const message = document.getElementById( 'wayfarer-tick-message' ); const onceButton = document.getElementById( 'wayfarer-tick-once' ); const autoButton = document.getElementById( 'wayfarer-tick-auto' ); const authorPauseButton = document.getElementById( 'wayfarer-tick-pause' ); const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); /* WAYFARER_HUD_SURFACES_LIVE_V95 */ const playerHuds = Array.from( document.querySelectorAll( '[data-wayfarer-hud]' ) ); /* WAYFARER_TRAVEL_TIME_HUD_V61 */ const travelTime = document.querySelector( '[data-wayfarer-travel-time]' ); const travelTimeSeparator = document.querySelector( '[data-wayfarer-travel-time-separator]' ); const travelNext = document.querySelector( '[data-wayfarer-travel-next]' ); const continueTravelButton = document.querySelector( '[data-wayfarer-continue-travel]' ); const playerPauseButton = document.querySelector( '[data-wayfarer-pause-travel]' ); /* WAYFARER_PLAYER_TURN_AROUND_UI_V72 */ const turnAroundButton = document.querySelector( '[data-wayfarer-turn-around]' ); /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Action Mode owns one physical card. Inventory, Status, and future * utility/context views temporarily replace the normal action heading/list * inside that card without becoming persisted runtime modes. */ const actionHeading = actionScreen ? actionScreen.querySelector( '.wayfarer-action-heading' ) : null; const actionList = actionScreen ? actionScreen.querySelector( '[data-wayfarer-action-list]' ) : null; const actionSubviewPanels = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ) : []; const actionViewOpeners = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-open-action-view]' ) ) : []; const actionViewClosers = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-close-action-view]' ) ) : []; /* * Persisted narrative presentation. */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); const presentationContinueButton = presentationScreen ? presentationScreen.querySelector( '[data-wayfarer-presentation-continue]' ) : null; let running = false; let requestInFlight = false; let presentationRequestInFlight = false; let storyletRequestInFlight = false; let timeoutId = null; let reloadWhenIdle = false; /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ let requestedTravelDirection = Number( config.initialDirection ) < 0 ? -1 : 1; let presentationActive = Boolean( presentationScreen && !presentationScreen.hidden ); function setMessage( text, isError = false ) { if (!message) { return; } message.textContent = text; message.style.color = isError ? 'red' : ''; } function emitModeEvent() { let mode = 'paused'; if (presentationActive) { mode = 'presentation'; } else if (running) { mode = 'running'; } window.dispatchEvent( new CustomEvent( 'wayfarer:runtime-mode', { detail: { mode: mode } } ) ); /* * Keep the v39 event names available for existing development hooks. */ window.dispatchEvent( new CustomEvent( running ? 'wayfarer:runtime-running' : 'wayfarer:runtime-paused', { detail: { mode: mode } } ) ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Generic in-card view switcher. Future Map / Shop / other views can use * the same data-wayfarer-open-action-view + data-wayfarer-action-view * contract without adding another page-level layout system. */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Resolve available views at the moment navigation occurs instead of * relying on the V48 page-load snapshot. This supports nested Entity * detail screens and future context views added to the same Action card. */ function setActionView(viewName = 'actions') { if (!actionScreen) { return; } const requested = String( viewName || 'actions' ); const showActions = requested === 'actions'; const panels = Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ); let matched = showActions; panels.forEach( function (panel) { const active = panel.dataset .wayfarerActionView === requested; panel.hidden = !active; if (active) { matched = true; } } ); /* * Fail safely if a malformed/removed view name is requested. */ if (!matched) { panels.forEach( function (panel) { panel.hidden = true; } ); if (actionHeading) { actionHeading.hidden = false; } if (actionList) { actionList.hidden = false; } return; } if (actionHeading) { actionHeading.hidden = !showActions; } if (actionList) { actionList.hidden = !showActions; } } function renderControls() { if (statusValue) { statusValue.textContent = presentationActive ? 'Presentation' : ( running ? 'Running' : 'Paused' ); } if (onceButton) { onceButton.disabled = running || requestInFlight || presentationActive; } if (autoButton) { autoButton.disabled = running || requestInFlight || presentationActive; } if (authorPauseButton) { authorPauseButton.disabled = !running; } if (continueTravelButton) { continueTravelButton.disabled = running || requestInFlight || presentationActive; } document .querySelectorAll( '[data-wayfarer-storylet-action-id], ' + '[data-wayfarer-storylet-branch-id]' ) .forEach( function (button) { button.disabled = requestInFlight || presentationRequestInFlight || storyletRequestInFlight || ( button.hasAttribute( 'data-wayfarer-storylet-action-id' ) && (running || presentationActive) ); } ); if (playerPauseButton) { playerPauseButton.disabled = !running; } /* WAYFARER_PLAYER_TURN_AROUND_UI_V72 */ if (turnAroundButton) { turnAroundButton.disabled = running || requestInFlight || presentationActive; } if (actionScreen) { actionScreen.hidden = running || presentationActive; } if (travelControls) { travelControls.hidden = !running || presentationActive; } /* * WAYFARER_HUD_SURFACE_VISIBILITY_V99 * * travel = middle, Travel only * action = bottom, Action only * both = bottom, persistent in Travel and Action */ playerHuds.forEach(function (hud) { const surface = String( hud.dataset.wayfarerHudSurface || 'travel' ); if (surface === 'both') { hud.hidden = presentationActive; return; } if (surface === 'action') { hud.hidden = presentationActive || running; return; } hud.hidden = presentationActive || !running; }); if (presentationScreen) { presentationScreen.hidden = !presentationActive; presentationScreen.setAttribute( 'aria-hidden', presentationActive ? 'false' : 'true' ); } body.classList.toggle( 'wayfarer-runtime-running', running ); body.classList.toggle( 'wayfarer-runtime-paused', !running && !presentationActive ); body.classList.toggle( 'wayfarer-runtime-presentation', presentationActive ); } /* * WAYFARER_LIVE_ACTION_CONTEXT_V46_1 * * Action Mode must reflect the current runtime position, not the page's * original server-rendered context. */ function renderPlayerContext(context) { if ( !context || typeof context !== 'object' ) { return; } const heading = document.querySelector( '[data-wayfarer-action-context-name]' ); const parent = document.querySelector( '[data-wayfarer-action-parent-context]' ); if (heading) { const name = String( context.context_name || 'Actions' ).trim(); heading.textContent = name || 'Actions'; } if (parent) { const parentText = String( context.parent_context || '' ).trim(); parent.textContent = parentText; parent.hidden = parentText === ''; } } /* WAYFARER_TRAVEL_TIME_HUD_RENDER_V61 */ function renderTravelStatus(travel) { if (!travel || typeof travel !== 'object') { return; } const timeLabel = String(travel.time_label || '').trim(); if (travelTime) { travelTime.textContent = timeLabel; travelTime.hidden = timeLabel === ''; } if (travelTimeSeparator) { travelTimeSeparator.hidden = timeLabel === ''; } const nextLabel = String(travel.next_label || '').trim(); if (travelNext) { travelNext.textContent = nextLabel === '' ? '' : 'Next: ' + nextLabel; travelNext.hidden = nextLabel === ''; } } function displayRuntimeValue(value) { if (typeof value === 'boolean') { return value ? 'True' : 'False'; } if ( value === null || value === undefined ) { return 'null'; } if (typeof value === 'object') { return JSON.stringify( value ); } return String( value ); } /* * WAYFARER_STORYLET_ACTIONS_LIVE_V59 * * Tick/presentation APIs return the currently eligible root Manual * Storylets so Action Mode does not depend on the page-load snapshot. */ function renderStoryletActions(actions) { if (!actionScreen) { return; } const host = actionScreen.querySelector( '[data-wayfarer-storylet-actions]' ); if (!host) { return; } const normalized = Array.isArray(actions) ? actions : []; host.innerHTML = ''; normalized.forEach( function (action) { const id = Number( action && action.id ? action.id : 0 ); if (id <= 0) { return; } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-action-button'; button.dataset.wayfarerStoryletActionId = String(id); button.textContent = String( action.label || action.name || 'Storylet' ); host.appendChild( button ); } ); const empty = actionScreen.querySelector( '[data-wayfarer-action-empty]' ); if (empty) { const canTravel = actionScreen.dataset.wayfarerCanTravel === '1'; empty.hidden = canTravel || host.children.length > 0; } renderControls(); } /* * WAYFARER_GAME_TRAVEL_HUD_LIVE_V94 * * HUD components carry a direct runtime Trait address. Tick responses * already include canonical state, so Number and Progress Bar values can * update in place without a separate HUD endpoint. */ function resolveHudTraits(state, scope) { if (!state || typeof state !== 'object') { return null; } if (scope === 'game') { return state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : null; } const match = String(scope || '').match(/^(region|location|entity):([1-9][0-9]*)$/); if (!match) { return null; } const bucket = match[1] === 'region' ? 'regions' : (match[1] === 'location' ? 'locations' : 'entities'); const key = match[2]; return state[bucket] && state[bucket][key] && state[bucket][key].traits && typeof state[bucket][key].traits === 'object' ? state[bucket][key].traits : null; } function formatHudNumber(value) { const number = Number(value); if (!Number.isFinite(number)) { return String(value ?? ''); } return Number.isInteger(number) ? String(number) : String(Math.round(number * 100) / 100); } function renderHudState(state) { if (!playerHuds.length || !state || typeof state !== 'object') { return; } playerHuds.forEach(function (playerHud) { playerHud .querySelectorAll('[data-wayfarer-hud-component]') .forEach(function (component) { const type = String(component.dataset.wayfarerHudType || 'number'); const scope = String(component.dataset.wayfarerHudScope || ''); const sourceId = String(component.dataset.wayfarerHudSourceId || ''); const traits = resolveHudTraits(state, scope); if ( !traits || !Object.prototype.hasOwnProperty.call(traits, sourceId) ) { return; } const value = Number(traits[sourceId]); if (!Number.isFinite(value)) { return; } const valueNode = component.querySelector('[data-wayfarer-hud-value]'); if (valueNode) { valueNode.textContent = formatHudNumber(value); } if (type !== 'progress_bar') { return; } const min = Number(component.dataset.wayfarerHudMin); const max = Number(component.dataset.wayfarerHudMax); if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) { return; } const percent = Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)); const fill = component.querySelector('[data-wayfarer-hud-fill]'); if (fill) { fill.style.width = percent + '%'; } const meter = component.querySelector('[data-wayfarer-hud-meter]'); if (meter) { meter.setAttribute('aria-valuenow', String(value)); } }); }); } function renderRuntimeState(state) { if ( !state || typeof state !== 'object' ) { return; } renderHudState( state ); if ( state.navigation && state.navigation.position !== undefined ) { const position = String( state.navigation.position ); const authorPosition = document.getElementById( 'wayfarer-author-position' ); if (authorPosition) { authorPosition.textContent = position; } } const gameTraits = state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : {}; document .querySelectorAll( '[data-wayfarer-game-trait-value]' ) .forEach( function (cell) { const traitId = cell.dataset .wayfarerGameTraitValue; if ( Object.prototype .hasOwnProperty .call( gameTraits, traitId ) ) { cell.textContent = displayRuntimeValue( gameTraits[traitId] ); } } ); document .querySelectorAll( '[data-wayfarer-region-trait-value]' ) .forEach( function (cell) { const regionId = cell.dataset .wayfarerRegionId; const traitId = cell.dataset .wayfarerRegionTraitValue; const traits = state.regions && state.regions[regionId] && state.regions[regionId].traits && typeof state.regions[regionId].traits === 'object' ? state.regions[regionId].traits : null; if ( traits && Object.prototype .hasOwnProperty .call( traits, traitId ) ) { cell.textContent = displayRuntimeValue( traits[traitId] ); } } ); const debug = document.getElementById( 'wayfarer-runtime-debug' ); if (debug) { debug.textContent = JSON.stringify( state, null, 2 ); } } async function requestTick() { if ( requestInFlight || presentationActive ) { return null; } requestInFlight = true; renderControls(); try { const response = await fetch( config.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ travel_direction: requestedTravelDirection }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Tick endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Tick request failed.' ); } if (tickValue) { tickValue.textContent = String( data.tick ); } renderRuntimeState( data.state ); /* WAYFARER_BIDIRECTIONAL_TRAVEL_UI_V71 */ if ( data.state && data.state.navigation && Object.prototype .hasOwnProperty .call( data.state.navigation, 'direction' ) ) { requestedTravelDirection = Number( data.state.navigation.direction ) < 0 ? -1 : 1; } renderPlayerContext( data.context ); renderTravelStatus( data.travel ); renderStoryletActions( data.actions ); setMessage( 'Tick ' + data.tick + ' saved.' ); if (data.pause === true) { /* * Suppress Action Mode during the brief interval before reload. * The server-rendered presentation will exist after reload. */ presentationActive = true; pauseRuntime( data.pause_reason || 'Travel interrupted.', true ); } return data; } catch (error) { pauseRuntime( error instanceof Error ? error.message : 'Tick request failed.' ); setMessage( error instanceof Error ? error.message : 'Tick request failed.', true ); return null; } finally { requestInFlight = false; renderControls(); if (reloadWhenIdle) { reloadWhenIdle = false; window.location.reload(); } } } async function runAutomaticTick() { if ( !running || presentationActive ) { return; } const result = await requestTick(); if ( !running || presentationActive || result === null ) { return; } timeoutId = window.setTimeout( runAutomaticTick, Number( config.intervalMs ) || 1500 ); } function startRuntime() { if ( running || requestInFlight || presentationActive ) { return; } /* * Travel always leaves inspection/context views and returns the card to * its normal Action view for the next pause. */ setActionView( 'actions' ); running = true; setMessage( 'Automatic advancement started.' ); renderControls(); emitModeEvent(); runAutomaticTick(); } /* * WAYFARER_PRESENTATION_TRANSITION_V63 * * Cover the current DOM before a server-rendered Presentation replaces it. * This prevents the previous Location/Storylet copy from being painted for * a frame during the navigation boundary. */ function beginPresentationTransition() { body.classList.add( 'wayfarer-presentation-transitioning' ); } function pauseRuntime( reason = 'Automatic advancement paused.', reloadForPresentation = false ) { const wasRunning = running; running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } setMessage( reason ); if (wasRunning) { setActionView( 'actions' ); } renderControls(); if (wasRunning) { emitModeEvent(); } if (reloadForPresentation) { /* WAYFARER_PRESENTATION_TRANSITION_V63 */ beginPresentationTransition(); if (requestInFlight) { reloadWhenIdle = true; } else { window.location.reload(); } } } /* * WAYFARER_STORYLET_ACTION_RUNTIME_V59 */ async function requestStorylet( storyletId, entryMode = 'action', parentStoryletId = 0 ) { if ( storyletRequestInFlight || requestInFlight || presentationRequestInFlight ) { return; } storyletId = Number(storyletId || 0); parentStoryletId = Number(parentStoryletId || 0); if (storyletId <= 0) { return; } storyletRequestInFlight = true; renderControls(); try { const response = await fetch( config.storyletEndpoint || 'api/storylet.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, storylet_id: storyletId, entry_mode: entryMode, parent_storylet_id: parentStoryletId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Storylet endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not enter Storylet.' ); } /* * Storylet presentation/Branch markup is server-rendered. Reload * only after the runtime transaction succeeds, so Effects cannot be * repeated by a failed or duplicate browser transition. */ /* WAYFARER_PRESENTATION_TRANSITION_V63 */ beginPresentationTransition(); window.location.reload(); } catch (error) { window.alert( error instanceof Error ? error.message : 'Could not enter Storylet.' ); } finally { storyletRequestInFlight = false; renderControls(); } } async function dismissPresentation() { if ( !presentationActive || !presentationScreen || presentationRequestInFlight ) { return; } presentationRequestInFlight = true; if (presentationContinueButton) { presentationContinueButton.disabled = true; } const presentationType = presentationScreen.dataset .wayfarerPresentationType || ''; const presentationId = Number( presentationScreen.dataset .wayfarerPresentationId || 0 ); try { const response = await fetch( config.presentationEndpoint || 'api/presentation.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, type: presentationType, id: presentationId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Presentation endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not continue.' ); } presentationActive = false; setActionView( 'actions' ); renderRuntimeState( data.state ); renderPlayerContext( data.context ); renderStoryletActions( data.actions ); renderControls(); emitModeEvent(); /* * Place keyboard focus on the first available Action so the * transition works cleanly for keyboard players too. */ const firstAction = actionScreen ? actionScreen.querySelector( 'button:not([disabled]), a[href]' ) : null; if ( firstAction && typeof firstAction.focus === 'function' ) { firstAction.focus(); } } catch (error) { if (presentationContinueButton) { presentationContinueButton.disabled = false; } window.alert( error instanceof Error ? error.message : 'Could not continue.' ); } finally { presentationRequestInFlight = false; /* WAYFARER_PRESENTATION_CONTROL_RESET_V62 * * dismissPresentation() previously refreshed Action controls while * presentationRequestInFlight was still true. Newly-rendered Manual * Storylet buttons were therefore left disabled after a Location or * Storylet presentation was dismissed. Re-render once the request * flag has actually been cleared. */ renderControls(); if ( presentationContinueButton && presentationActive ) { presentationContinueButton.disabled = false; } } } if (onceButton) { onceButton.addEventListener( 'click', async function () { if ( running || presentationActive ) { return; } setMessage(''); await requestTick(); } ); } if (autoButton) { autoButton.addEventListener( 'click', startRuntime ); } if (authorPauseButton) { authorPauseButton.addEventListener( 'click', function () { pauseRuntime( 'Automatic advancement paused.' ); } ); } /* WAYFARER_PLAYER_TURN_AROUND_UI_V72 */ if (turnAroundButton) { turnAroundButton.addEventListener( 'click', function () { requestedTravelDirection = requestedTravelDirection < 0 ? 1 : -1; startRuntime(); } ); } if (continueTravelButton) { continueTravelButton.addEventListener( 'click', startRuntime ); } if (playerPauseButton) { playerPauseButton.addEventListener( 'click', function () { /* * WAYFARER_SMOOTH_MANUAL_PAUSE_V46_2 * * v46.1 keeps Action context current in-place, so manual Pause * no longer needs a full page reload. */ pauseRuntime( 'Travel paused.' ); } ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Ensure V50 detail panels physically belong to the Action card. This is * harmless when the PHP view already placed them correctly, and repairs * any earlier V50 markup that landed just outside the host. */ if (actionScreen) { document .querySelectorAll( '.wayfarer-entity-detail-view' ) .forEach( function (panel) { if ( !actionScreen.contains( panel ) ) { actionScreen.appendChild( panel ); } } ); /* * Delegate navigation from the Action card itself. Any current or * future control using the generic data attributes automatically * participates in the same router. */ actionScreen.addEventListener( 'click', function (event) { const storyletAction = event.target.closest( '[data-wayfarer-storylet-action-id]' ); if ( storyletAction && actionScreen.contains( storyletAction ) ) { if ( running || presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( storyletAction.dataset .wayfarerStoryletActionId, 'action', 0 ); return; } const opener = event.target.closest( '[data-wayfarer-open-action-view]' ); if ( opener && actionScreen.contains( opener ) ) { if ( running || presentationActive ) { return; } event.preventDefault(); setActionView( opener.dataset .wayfarerOpenActionView || 'actions' ); return; } const closer = event.target.closest( '[data-wayfarer-close-action-view]' ); if ( !closer || !actionScreen.contains( closer ) ) { return; } event.preventDefault(); const returnView = closer.dataset .wayfarerReturnActionView || 'actions'; setActionView( returnView ); let focusTarget = null; if (returnView === 'actions') { focusTarget = actionList ? actionList.querySelector( 'button:not([disabled]), a[href]' ) : null; } else { const parentPanel = actionScreen.querySelector( '[data-wayfarer-action-view="' + returnView + '"]' ); focusTarget = parentPanel ? parentPanel.querySelector( '[data-wayfarer-open-action-view], button:not([disabled]), a[href]' ) : null; } if ( focusTarget && typeof focusTarget.focus === 'function' ) { focusTarget.focus(); } } ); } /* WAYFARER_STORYLET_BRANCH_SELECTION_V59 */ document.addEventListener( 'click', function (event) { const branch = event.target.closest( '[data-wayfarer-storylet-branch-id]' ); if (!branch) { return; } if ( !presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( branch.dataset .wayfarerStoryletBranchId, 'branch', branch.dataset .wayfarerStoryletParentId ); } ); if (presentationContinueButton) { presentationContinueButton.addEventListener( 'click', dismissPresentation ); } document.addEventListener( 'keydown', function (event) { if ( !presentationActive || presentationRequestInFlight || storyletRequestInFlight || !presentationContinueButton ) { return; } const target = event.target; if ( target && target.matches && target.matches( 'input, textarea, select, button, a[href], [contenteditable="true"]' ) ) { return; } if ( event.key !== 'Enter' && event.key !== ' ' ) { return; } event.preventDefault(); dismissPresentation(); } ); window.addEventListener( 'pagehide', function () { running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } } ); window.wayfarerRuntimeController = { start: startRuntime, pause: pauseRuntime, dismissPresentation: dismissPresentation, enterStorylet: requestStorylet, isRunning: function () { return running; }, mode: function () { if (presentationActive) { return 'presentation'; } return running ? 'running' : 'paused'; } }; setActionView( 'actions' ); renderControls(); emitModeEvent(); })(); /* |-------------------------------------------------------------------------- | WAYFARER PLAY SHELL v20 |-------------------------------------------------------------------------- | | Reorganizes existing server-rendered nodes into the viewport shell. | Existing nodes are MOVED, not cloned/replaced, so the tick-control | event listeners installed above remain intact. | */ (() => { 'use strict'; if ( document.getElementById( 'wayfarer-play-shell' ) ) { return; } function exactHeading( level, text ) { const headings = document.querySelectorAll( level ); for (const heading of headings) { if ( heading.textContent .replace(/\s+/g, ' ') .trim() === text ) { return heading; } } return null; } function moveRange( start, stopExclusive, destination ) { if ( !start || !destination ) { return; } let node = start; while ( node && node !== stopExclusive ) { const next = node.nextSibling; destination.appendChild( node ); node = next; } } const body = document.body; /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * PHP decides whether Author Mode is permitted. JavaScript only shapes the * interface around that server-side decision. */ const canUseAuthorMode = body.dataset.wayfarerAuthorMode === '1'; body.classList.add( 'wayfarer-play-page' ); const shell = document.createElement( 'div' ); shell.id = 'wayfarer-play-shell'; shell.className = 'wayfarer-play-shell'; const header = document.createElement( 'header' ); header.className = 'wayfarer-play-shell-header'; const title = document.createElement( 'div' ); title.className = 'wayfarer-play-title'; const actions = document.createElement( 'div' ); actions.className = 'wayfarer-shell-actions'; const toggleAuthor = document.createElement( 'button' ); toggleAuthor.type = 'button'; toggleAuthor.className = 'wayfarer-shell-button'; if (canUseAuthorMode) { actions.appendChild( toggleAuthor ); } header.appendChild( title ); if (canUseAuthorMode) { header.appendChild( actions ); } const main = document.createElement( 'main' ); main.className = 'wayfarer-play-main'; const playerPane = document.createElement( 'section' ); playerPane.className = 'wayfarer-player-pane'; const authorPane = document.createElement( 'aside' ); authorPane.className = 'wayfarer-author-pane'; const authorHeader = document.createElement( 'div' ); authorHeader.className = 'wayfarer-author-header'; const authorToolbar = document.createElement( 'div' ); authorToolbar.className = 'wayfarer-author-toolbar'; const tabBar = document.createElement( 'div' ); tabBar.className = 'wayfarer-author-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const authorContent = document.createElement( 'div' ); authorContent.className = 'wayfarer-author-content'; authorPane.appendChild( authorHeader ); authorPane.appendChild( authorToolbar ); authorPane.appendChild( tabBar ); authorPane.appendChild( authorContent ); main.appendChild( playerPane ); if (canUseAuthorMode) { main.appendChild( authorPane ); } else { /* * Keep the player workspace full-width when Author Mode is unavailable. * No Author pane was rendered by PHP and none is attached here. */ shell.classList.add( 'author-collapsed' ); } shell.appendChild( header ); shell.appendChild( main ); body.insertBefore( shell, body.firstChild ); // ------------------------------------------------------------ // Top title/player identity // ------------------------------------------------------------ const pageTitle = Array.from( body.querySelectorAll( ':scope > h1' ) )[0]; if (pageTitle) { const playerIdentity = pageTitle.nextElementSibling; title.appendChild( pageTitle ); if ( playerIdentity && playerIdentity.tagName === 'P' ) { title.appendChild( playerIdentity ); } } // ------------------------------------------------------------ // Player content // ------------------------------------------------------------ /* * WAYFARER_PLAYER_START_SCREEN_V36 * * Before runtime state exists, the start screen owns the full player * workspace and Author Mode is intentionally hidden. */ const startScreen = document.querySelector( '[data-wayfarer-start-screen]' ); if (startScreen) { shell.classList.add( 'is-starting' ); playerPane.appendChild( startScreen ); } const scene = document.getElementById( 'wayfarer-player-scene' ); const gameStateHeading = exactHeading( 'h2', 'Game State' ) || exactHeading( 'h2', 'Game Not Initialized' ); if (gameStateHeading) { const meta = document.createElement( 'div' ); meta.className = 'wayfarer-player-meta'; /* * Move from Game State through the node immediately before the * player scene. This includes initialization messaging/forms but * stops before the actual scene. */ moveRange( gameStateHeading, scene, meta ); playerPane.appendChild( meta ); } if (scene) { playerPane.appendChild( scene ); } /* * WAYFARER_ACTION_SCREEN_V39 * * Action Mode is player-facing and exists independently of Author Mode. */ const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); if (actionScreen) { playerPane.appendChild( actionScreen ); } /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); if (presentationScreen) { playerPane.appendChild( presentationScreen ); } const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); if (travelControls) { playerPane.appendChild( travelControls ); } /* * WAYFARER_HUD_STABLE_BOTTOM_V98 * * Placement semantics: * * Travel-only items live in the middle Travel area, directly below the * Travel status/controls card. * * Action-only items live in the persistent bottom HUD area but are visible * only while stopped/on the Action screen. * * Travel & Action items also live in that same persistent bottom HUD area * and remain there in both modes. They do not move into the Travel middle * area when travel starts. */ document .querySelectorAll('[data-wayfarer-hud]') .forEach(function (persistentHud) { const surface = String( persistentHud.dataset .wayfarerHudSurface || 'travel' ); if ( surface === 'travel' && travelControls && travelControls.parentNode ) { travelControls.insertAdjacentElement( 'afterend', persistentHud ); return; } /* * WAYFARER_HUD_THREE_SURFACE_PLACEMENT_V99 * * Action and Both are separate physical HUD sections, but both * belong at the bottom of the player pane. Visibility logic hides * Action during Travel while Both remains visible. */ playerPane.appendChild( persistentHud ); }); // ------------------------------------------------------------ // Author header // ------------------------------------------------------------ const authorHeading = exactHeading( 'h2', 'Author Mode' ); if (authorHeading) { const builderLinkBlock = authorHeading.nextElementSibling; authorHeader.appendChild( authorHeading ); if ( builderLinkBlock && builderLinkBlock.tagName === 'P' ) { authorHeader.appendChild( builderLinkBlock ); } } // ------------------------------------------------------------ // Runtime controls // ------------------------------------------------------------ const tickPanel = document.getElementById( 'wayfarer-tick-test' ); if (tickPanel) { authorToolbar.appendChild( tickPanel ); } // ------------------------------------------------------------ // Author tabs // ------------------------------------------------------------ /* WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 */ const panelDefinitions = [ { key: 'state', label: 'State' }, { key: 'debug', label: 'Debug' } ]; function activateTab(key) { document .querySelectorAll( '.wayfarer-author-tab' ) .forEach( function (button) { const active = button.dataset .wayfarerAuthorTab === key; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); authorContent .querySelectorAll( '.wayfarer-author-tab-panel' ) .forEach( function (panel) { panel.classList.toggle( 'is-active', panel.dataset .wayfarerAuthorPanel === key ); } ); try { window.localStorage.setItem( 'wayfarerAuthorTab', key ); } catch (error) { // Storage is optional. } } for (const definition of panelDefinitions) { const panel = document.querySelector( '[data-wayfarer-author-panel="' + definition.key + '"]' ); if (!panel) { continue; } authorContent.appendChild( panel ); const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-author-tab'; button.dataset.wayfarerAuthorTab = definition.key; button.setAttribute( 'role', 'tab' ); button.textContent = definition.label; button.addEventListener( 'click', function () { activateTab( definition.key ); } ); tabBar.appendChild( button ); } let initialTab = 'state'; try { let savedTab = window.localStorage.getItem( 'wayfarerAuthorTab' ); if ( savedTab === 'region' ) { savedTab = 'state'; window.localStorage.setItem( 'wayfarerAuthorTab', 'state' ); } if ( savedTab && authorContent.querySelector( '[data-wayfarer-author-panel="' + savedTab + '"]' ) ) { initialTab = savedTab; } } catch (error) { // Storage is optional. } activateTab( initialTab ); // ------------------------------------------------------------ // Hide / Show Author Mode // WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 // ------------------------------------------------------------ if (canUseAuthorMode) { function renderAuthorToggle() { const collapsed = shell.classList.contains( 'author-collapsed' ); toggleAuthor.textContent = collapsed ? 'Show Author' : 'Hide Author'; toggleAuthor.setAttribute( 'aria-expanded', collapsed ? 'false' : 'true' ); } toggleAuthor.addEventListener( 'click', function () { shell.classList.toggle( 'author-collapsed' ); try { window.localStorage.setItem( 'wayfarerAuthorCollapsed', shell.classList.contains( 'author-collapsed' ) ? '1' : '0' ); } catch (error) { // Storage is optional. } renderAuthorToggle(); } ); try { if ( window.localStorage.getItem( 'wayfarerAuthorCollapsed' ) === '1' ) { shell.classList.add( 'author-collapsed' ); } } catch (error) { // Storage is optional. } renderAuthorToggle(); } else { /* * Player role is not merely a hidden Author interface. Keep the shell * permanently in player-only layout. */ shell.classList.add( 'author-collapsed' ); } // ------------------------------------------------------------ // Remove otherwise-empty top-level HRs left by the old document flow. // ------------------------------------------------------------ body .querySelectorAll( ':scope > hr' ) .forEach( function (rule) { rule.remove(); } ); })(); /* |-------------------------------------------------------------------------- | ENDING RESTART | WAYFARER_ENDING_RESTART_V63 |-------------------------------------------------------------------------- */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; const restartButton = document.querySelector( '[data-wayfarer-restart-game]' ); if ( !config || !restartButton ) { return; } let requestInFlight = false; restartButton.addEventListener( 'click', async function () { if (requestInFlight) { return; } if ( !window.confirm( 'Start this game over? Your current playthrough progress will be erased.' ) ) { return; } requestInFlight = true; restartButton.disabled = true; const originalText = restartButton.textContent; restartButton.textContent = 'Starting over…'; try { const response = await fetch( config.restartEndpoint || 'api/restart.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Restart endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not restart the game.' ); } document.body.classList.add( 'wayfarer-presentation-transitioning' ); window.location.href = 'index.php'; } catch (error) { requestInFlight = false; restartButton.disabled = false; restartButton.textContent = originalText; window.alert( error instanceof Error ? error.message : 'Could not restart the game.' ); } } ); })(); ================================================================================ FILE: play/includes/action-screen.php ================================================================================ 0) { $actionRegionStmt = $pdo->prepare( " SELECT id, name, navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $actionRegionStmt->execute([ $actionRegionId, (int)$gameId, ]); $actionRegion = $actionRegionStmt->fetch( PDO::FETCH_ASSOC ); if ($actionRegion) { $actionRegionNavigationType = (string)( $actionRegion['navigation_type'] ?? '' ); $actionContextName = trim( (string)( $actionRegion['name'] ?? '' ) ) ?: 'Actions'; } } $actionLocation = wayfarerRuntimeFindCurrentLocation( $pdo, (int)$gameId, $state ); if ($actionLocation) { $locationName = trim( (string)( $actionLocation['name'] ?? '' ) ); if ($locationName !== '') { $actionContextName = $locationName; } } $actionCanTravel = strtolower( trim( $actionRegionNavigationType ) ) === 'linear'; /* WAYFARER_STORYLET_ACTION_SCREEN_V59 */ $actionStorylets = wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$gameId, $state ); /* * WAYFARER_ENTITY_PRESENTATION_V49 * * The V48 card switcher remains generic. V49 supplies the Inventory / Status * view models from canonical runtime Entity state. */ $actionEntityPresentation = wayfarerRuntimeBuildEntityPresentation( $pdo, (int)$gameId, $state, is_array($gameConfig ?? null) ? $gameConfig : [] ); $actionInventory = $actionEntityPresentation['inventory'] ?? []; $actionParty = $actionEntityPresentation['party'] ?? []; /* WAYFARER_PLAYER_TRAITS_STATUS_V52 */ $actionPlayerPresentation = wayfarerRuntimeBuildPlayerPresentation( is_array($gameConfig ?? null) ? $gameConfig : [], $state ); $actionPlayers = $actionPlayerPresentation['players'] ?? []; $actionGameStatusTraits = $actionPlayerPresentation['game_status_traits'] ?? []; /* WAYFARER_STATUSES_PRESENTATION_V87 */ foreach ($actionPlayers as &$actionStatusPlayer) { $slot = (int)( $actionStatusPlayer['slot'] ?? 0 ); $actionStatusPlayer['statuses'] = wayfarerRuntimeVisibleStatusesForScope( $pdo, (int)$gameId, $state, 'player:' . $slot ); } unset($actionStatusPlayer); foreach ($actionParty as &$actionStatusCharacter) { $entityId = (int)( $actionStatusCharacter['id'] ?? 0 ); $actionStatusCharacter['statuses'] = $entityId > 0 ? wayfarerRuntimeVisibleStatusesForScope( $pdo, (int)$gameId, $state, 'entity:' . $entityId ) : []; } unset($actionStatusCharacter); /* * WAYFARER_STATUS_TRAIT_DISPLAY_V86 * * Status Traits may render as a Number, a Bar, or Both. Runtime presentation * metadata already falls back to Number when a bar cannot be calculated. */ $renderStatusTrait = static function ( array $trait ): void { $name = trim( (string)( $trait['name'] ?? '' ) ); $value = (string)( $trait['value'] ?? '' ); $mode = strtolower( trim( (string)( $trait['display_mode'] ?? 'number' ) ) ); if ( !in_array( $mode, ['number', 'bar', 'both'], true ) ) { $mode = 'number'; } $showBar = !empty( $trait['bar_available'] ) && is_numeric( $trait['bar_percent'] ?? null ) && ( $mode === 'bar' || $mode === 'both' ); if (!$showBar && $mode !== 'number') { $mode = 'number'; } $showValue = $mode === 'number' || $mode === 'both'; $percent = $showBar ? max( 0, min( 100, (float)$trait['bar_percent'] ) ) : 0; $minimum = $trait['bar_min'] ?? 0; $maximum = $trait['bar_max'] ?? 100; $current = $trait['bar_value'] ?? 0; ?>

>

What would you like to do?

No actions are currently available.

================================================================================ FILE: play/includes/applicable-traits.v15-disabled.php ================================================================================ getMessage(); } ?>

Applicable Traits

Current Region: (ID )

No current Region. The game does not currently resolve to a valid authored starting Region.

Scope Trait Current Value Type

No active Traits are currently applicable.

================================================================================ FILE: play/includes/author-mode.php ================================================================================

Author Mode

Open Wayfarer Builder

Runtime

Runtime state has not been initialized.

Game Setup

Player Name Slots
Starting Choices
Starting Region ID Not configured

Runtime Debug

NULL

Game Traits

No game traits are currently stored.

$value ): ?>
Trait Current Value Type
Unknown Trait

Runtime Debug

================================================================================ FILE: play/includes/current-region.php ================================================================================ prepare( " SELECT * FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); return $row ?: null; } function wayfarerV16EnsureCurrentRegion( PDO $pdo, int $gameUserId ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $gameUser = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$gameUser) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $gameId = (int)$gameUser['game_id']; $state = wayfarerRuntimeDecodeState( $gameUser['state'] ); if ( !isset($state['navigation']) || !is_array($state['navigation']) ) { $state['navigation'] = []; } $regionId = (int)( $state['navigation']['region_id'] ?? 0 ); $region = wayfarerV16LoadRegion( $pdo, $gameId, $regionId ); $changed = false; /* * WAYFARER_RUNTIME_INITIALIZATION_V31 * * Recovery only: properly initialized v31 state already contains its * Region. If older/incomplete state lacks a valid Region, recover to * the Game's authored Starting Region — never an arbitrary first row. */ if (!$region) { $region = wayfarerRuntimeLoadStartingRegion( $pdo, $gameId ); if ($region) { $regionId = (int)$region['id']; $state['navigation']['region_id'] = $regionId; if ( strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear' && !array_key_exists( 'position', $state['navigation'] ) ) { $state['navigation']['position'] = 0; } $changed = true; } } $traits = []; if ($region) { $regionConfig = wayfarerV16DecodeConfig( $region['config'] ?? null ); $traits = wayfarerV16ActiveTraits( $regionConfig ); if ( !isset($state['regions']) || !is_array($state['regions']) ) { $state['regions'] = []; $changed = true; } $regionKey = (string)$regionId; if ( !isset($state['regions'][$regionKey]) || !is_array( $state['regions'][$regionKey] ) ) { $state['regions'][$regionKey] = []; $changed = true; } if ( !isset( $state['regions'][$regionKey]['traits'] ) || !is_array( $state['regions'][$regionKey]['traits'] ) ) { $state['regions'][$regionKey]['traits'] = []; $changed = true; } foreach ($traits as $trait) { $traitId = (string)$trait['_runtime_id']; if ( array_key_exists( $traitId, $state['regions'][$regionKey]['traits'] ) ) { continue; } $state['regions'][$regionKey]['traits'][$traitId] = wayfarerV16InitialTraitValue( $trait ); $changed = true; } } if ($changed) { $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ wayfarerRuntimeEncodeState( $state ), $gameUserId, ]); } $pdo->commit(); return [ 'region' => $region, 'traits' => $traits, 'state' => $state, 'region_id' => $regionId, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } $currentRegionError = ''; $currentRegionContext = null; try { $requestedGameUserId = isset($_GET['game_user_id']) ? (int)$_GET['game_user_id'] : null; $currentRegionGameUserId = wayfarerRuntimeResolveGameUserId( $pdo, $requestedGameUserId ); $currentRegionContext = wayfarerV16EnsureCurrentRegion( $pdo, $currentRegionGameUserId ); } catch (Throwable $exception) { $currentRegionError = $exception->getMessage(); } ?>

Current Region

No active Region is available for this game.

—

Region ID:   |   Position:

Region Traits

This Region has no active Traits.

Trait Current Value Type
================================================================================ FILE: play/includes/effects.php ================================================================================ true, 'value' => wayfarerNormalizeRuntimeNumber( (float)$effectValue ), 'error' => '', ]; } return [ 'applied' => true, 'value' => $effectValue, 'error' => '', ]; } if ( $operation !== 'add' && $operation !== 'subtract' ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Unsupported operation: ' . $operation, ]; } if ( !is_numeric($currentValue) || !is_numeric($effectValue) ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Add/subtract requires numeric values.', ]; } $current = (float)$currentValue; $amount = (float)$effectValue; $result = $operation === 'add' ? $current + $amount : $current - $amount; return [ 'applied' => true, 'value' => wayfarerNormalizeRuntimeNumber( $result ), 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Tick Effect branch runtime |-------------------------------------------------------------------------- | | A Tick Effect owns one target and an ordered list of result branches. | Conditions are evaluated against the CURRENT runtime state. Because Tick | Effects execute sequentially, a later Tick Effect can see state changes made | by an earlier Tick Effect during the same tick. | */ function wayfarerRuntimeResolveConditionTarget( array $runtimeState, int $currentRegionId, string $scope, string $targetType, string $targetId ): array { $scope = strtolower( trim( $scope ) ); $targetType = strtolower( trim( $targetType ) ); $targetId = trim( $targetId ); // ------------------------------------------------------------ // PARTY-MEMBER STATUS // WAYFARER_STATUS_CONDITION_RUNTIME_V87 // ------------------------------------------------------------ if ( $targetType === 'status' && preg_match('/^[1-9][0-9]*$/', $targetId) === 1 && preg_match( '/^(?:player:[0-9]+|entity:[1-9][0-9]*)$/', $scope ) === 1 ) { $statusState = wayfarerRuntimeStatusState( $runtimeState, $scope, (int)$targetId ); if (empty($statusState['found_owner'])) { return [ 'found' => false, 'value' => null, ]; } return [ 'found' => true, 'value' => !empty($statusState['active']) ? 1 : 0, ]; } // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $targetType === 'trait' && $targetId !== '' ) { $traits = $runtimeState['game']['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // SPECIFIC REGION TRAIT // WAYFARER_SPECIFIC_REGION_CONDITION_SCOPE_V46 // ------------------------------------------------------------ // // Game-level condition editors store a Region-owned Trait as // "region:". This lets a global rule inspect that Region whether // or not the player is currently there. // if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^region:([1-9][0-9]*)$/', $scope, $regionScopeMatch ) === 1 ) { $regionKey = (string)( (int)$regionScopeMatch[1] ); $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $targetType === 'trait' && $targetId !== '' && $currentRegionId > 0 ) { $regionKey = (string)$currentRegionId; $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // PLAYER / LOCATION / ENTITY TRAITS // WAYFARER_RUNTIME_UNIVERSAL_TARGET_RESOLVER_V56 // ------------------------------------------------------------ if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^player:([0-9]+)$/', $scope, $playerScopeMatch ) === 1 ) { $playerKey = (string)((int)$playerScopeMatch[1]); $traits = $runtimeState['players'][$playerKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^location:([1-9][0-9]*)$/', $scope, $locationScopeMatch ) === 1 ) { $locationKey = (string)((int)$locationScopeMatch[1]); $traits = $runtimeState['locations'][$locationKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityScopeMatch ) === 1 ) { $entityKey = (string)((int)$entityScopeMatch[1]); $traits = $runtimeState['entities'][$entityKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // GAME TIME // WAYFARER_HIERARCHICAL_TARGETS_V80 // ------------------------------------------------------------ if ( $scope === 'time' && $targetType === 'clock' ) { $clock = $runtimeState['runtime']['clock'] ?? null; if (!is_array($clock)) { return [ 'found' => false, 'value' => null, ]; } if ( $targetId === 'day' && array_key_exists( 'day', $clock ) ) { return [ 'found' => true, 'value' => (int)$clock['day'], ]; } if ( $targetId === 'hour' && array_key_exists( 'hour', $clock ) ) { return [ 'found' => true, 'value' => (int)$clock['hour'], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // REGION-QUALIFIED NAVIGATION POSITION // WAYFARER_LOCATION_CONDITION_TARGETS_V75 // ------------------------------------------------------------ if ( $targetType === 'position' && $targetId === 'position' && preg_match( '/^navigation:([1-9][0-9]*)$/', $scope, $navigationRegionMatch ) === 1 ) { $requiredRegionId = (int)$navigationRegionMatch[1]; if ( $requiredRegionId !== $currentRegionId ) { return [ 'found' => false, 'value' => null, ]; } if ( isset( $runtimeState['navigation'] ) && is_array( $runtimeState['navigation'] ) && array_key_exists( 'position', $runtimeState['navigation'] ) ) { return [ 'found' => true, 'value' => $runtimeState['navigation']['position'], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // LIVE LOCATION ANCHOR // WAYFARER_LIVE_LOCATION_ANCHORS_V76 // ------------------------------------------------------------ if ( $scope === 'navigation' && $targetType === 'location' && preg_match( '/^[1-9][0-9]*$/', $targetId ) === 1 ) { $locationKey = (string)((int)$targetId); $anchor = $runtimeState['locations'][$locationKey] ?? null; if (!is_array($anchor)) { return [ 'found' => false, 'value' => null, ]; } $anchorRegionId = (int)( $anchor['region_id'] ?? 0 ); $anchorPosition = $anchor['position'] ?? null; if ( $anchorRegionId <= 0 || !is_numeric($anchorPosition) ) { return [ 'found' => false, 'value' => null, ]; } /* * Before/After has no meaning across Regions. "Not at" is handled by * the Condition engine via comparison_scope_match=false. */ if ($anchorRegionId !== $currentRegionId) { return [ 'found' => true, 'value' => null, 'comparison_scope_match' => false, ]; } $currentPosition = $runtimeState['navigation']['position'] ?? null; if (!is_numeric($currentPosition)) { return [ 'found' => false, 'value' => null, ]; } return [ 'found' => true, 'value' => wayfarerNormalizeRuntimeNumber( (float)$currentPosition - (float)$anchorPosition ), 'comparison_scope_match' => true, ]; } // ------------------------------------------------------------ // NAMED LOCATION = CURRENT REGION + CURRENT NUMERIC POSITION // WAYFARER_LOCATION_CONDITION_TARGETS_V75 // ------------------------------------------------------------ if ( $scope === 'navigation' && $targetType === 'location' && $targetId === 'location' ) { if ( $currentRegionId <= 0 || !isset( $runtimeState['navigation'] ) || !is_array( $runtimeState['navigation'] ) || !array_key_exists( 'position', $runtimeState['navigation'] ) || !is_numeric( $runtimeState['navigation']['position'] ) ) { return [ 'found' => false, 'value' => null, ]; } $position = wayfarerNormalizeRuntimeNumber( (float)$runtimeState[ 'navigation' ]['position'] ); return [ 'found' => true, 'value' => $currentRegionId . ':' . (string)$position, ]; } // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $targetType === 'position' && $targetId === 'position' ) { if ( isset( $runtimeState['navigation'] ) && is_array( $runtimeState['navigation'] ) && array_key_exists( 'position', $runtimeState['navigation'] ) ) { return [ 'found' => true, 'value' => $runtimeState['navigation']['position'], ]; } return [ 'found' => false, 'value' => null, ]; } return [ 'found' => false, 'value' => null, ]; } function wayfarerRuntimeTickEffectBranches( array $effect ): array { /* * v28+ canonical structure. * * If the branches key exists, it is authoritative. An empty branches * array therefore means "this effect has no executable results" rather * than silently falling back to legacy compatibility fields. */ if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * Legacy pre-v28 structure. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'conditions', $effect ) ) { $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $effect['operation'] ?? '', 'value_mode' => $effect['value_mode'] ?? 'fixed', ]; if ( strtolower( trim( (string)$branch['value_mode'] ) ) === 'random' ) { $branch['value_min'] = $effect['value_min'] ?? 0; $branch['value_max'] = $effect['value_max'] ?? 0; } else { $branch['value'] = $effect['value'] ?? 0; } return [ $branch ]; } return []; } function wayfarerRuntimeSelectTickEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeTickEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Tick Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; /* * Validate before evaluating. Invalid authored data fails closed and * does NOT fall through to OTHERWISE, because doing so could silently * produce the wrong gameplay result. */ $validation = wayfarerConditionValidateNode( $conditions ); if (!$validation['valid']) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Tick Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Runtime Trait bounds |-------------------------------------------------------------------------- | | WAYFARER_RUNTIME_TRAIT_BOUNDS_V30 | | Authored numeric Trait min/max values are runtime invariants. | State is normalized before condition evaluation and every subsequent | mutation is clamped again before it is written. | */ function wayfarerRuntimeDecodeTraitConfig( $rawConfig ): array { if (is_array($rawConfig)) { return $rawConfig; } if ( $rawConfig === null || $rawConfig === '' ) { return []; } $decoded = json_decode( (string)$rawConfig, true ); return is_array($decoded) ? $decoded : []; } function wayfarerRuntimeIndexTraitDefinitions( array $traits ): array { $definitions = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $definitions[$traitId] = $trait; } return $definitions; } function wayfarerRuntimeLoadTickTraitDefinitions( PDO $pdo, int $gameId, array $regionConfig ): array { $gameConfig = []; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawGameConfig = $stmt->fetchColumn(); if ($rawGameConfig !== false) { $gameConfig = wayfarerRuntimeDecodeTraitConfig( $rawGameConfig ); } } /* WAYFARER_RUNTIME_UNIVERSAL_DEFINITIONS_V56 */ $definitions = [ 'game' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['traits'] ?? null ) ? $gameConfig['traits'] : [] ), 'player' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['player_traits'] ?? null ) ? $gameConfig['player_traits'] : [] ), 'region' => wayfarerRuntimeIndexTraitDefinitions( is_array( $regionConfig['traits'] ?? null ) ? $regionConfig['traits'] : [] ), 'regions' => [], 'locations' => [], 'entities' => [], ]; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $definitions['regions'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } $stmt = $pdo->prepare( " SELECT l.id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $definitions['locations'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } $stmt = $pdo->prepare( " SELECT id, entity_type, config FROM wf_entities WHERE game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); if ( function_exists( 'wayfarerEntityActiveTraitDefinitions' ) ) { $definitions['entities'][$id] = wayfarerEntityActiveTraitDefinitions( $config, (string)( $row['entity_type'] ?? '' ) ); } else { $definitions['entities'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } } } return $definitions; } function wayfarerRuntimeClampValueToTraitDefinition( $value, ?array $definition ): array { $result = [ 'valid' => true, 'value' => $value, 'clamped' => false, 'min' => null, 'max' => null, 'error' => '', ]; /* * Bounds currently belong only to authored Number Traits. */ if ( !$definition || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' ) { return $result; } if (!is_numeric($value)) { $result['valid'] = false; $result['error'] = 'Numeric Trait runtime state must be numeric.'; return $result; } $min = isset($definition['min']) && $definition['min'] !== '' && is_numeric($definition['min']) ? (float)$definition['min'] : null; $max = isset($definition['max']) && $definition['max'] !== '' && is_numeric($definition['max']) ? (float)$definition['max'] : null; $numeric = (float)$value; $bounded = $numeric; if ( $min !== null && $bounded < $min ) { $bounded = $min; } if ( $max !== null && $bounded > $max ) { $bounded = $max; } $result['value'] = wayfarerNormalizeRuntimeNumber( $bounded ); $result['clamped'] = $bounded !== $numeric; $result['min'] = $min === null ? null : wayfarerNormalizeRuntimeNumber( $min ); $result['max'] = $max === null ? null : wayfarerNormalizeRuntimeNumber( $max ); return $result; } function wayfarerRuntimeApplyBoundedTraitOperation( $storedBefore, string $operation, $effectValue, ?array $definition ): array { /* * Repair an already-illegal persisted value BEFORE applying the new * operation. Example: 120 with max 100 and subtract 10 becomes * 100 - 10 = 90, not 120 - 10 = 110 -> 100. */ $beforeBound = wayfarerRuntimeClampValueToTraitDefinition( $storedBefore, $definition ); if (!$beforeBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $storedBefore, 'requested_after' => $storedBefore, 'after' => $storedBefore, 'clamped' => false, 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $beforeBound['error'], ]; } $normalizedBefore = $beforeBound['value']; $operationResult = wayfarerApplyRuntimeOperation( $normalizedBefore, $operation, $effectValue ); if (!$operationResult['applied']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $normalizedBefore, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $operationResult['error'], ]; } $requestedAfter = $operationResult['value']; $afterBound = wayfarerRuntimeClampValueToTraitDefinition( $requestedAfter, $definition ); if (!$afterBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $afterBound['error'], ]; } return [ 'applied' => true, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $afterBound['value'], 'clamped' => $beforeBound['clamped'] || $afterBound['clamped'], 'min' => $afterBound['min'], 'max' => $afterBound['max'], 'error' => '', ]; } function wayfarerRuntimeClampApplicableTraitState( array &$runtimeState, array $traitDefinitions, int $currentRegionId ): array { $repairs = []; // ------------------------------------------------------------ // GAME TRAITS // ------------------------------------------------------------ $gameTraits = $runtimeState['game']['traits'] ?? null; if (is_array($gameTraits)) { foreach ( $traitDefinitions['game'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $gameTraits ) ) { continue; } $before = $runtimeState['game']['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['game']['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } // ------------------------------------------------------------ // CURRENT REGION TRAITS // ------------------------------------------------------------ $regionKey = (string)$currentRegionId; $regionTraits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if (is_array($regionTraits)) { foreach ( $traitDefinitions['region'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $regionTraits ) ) { continue; } $before = $runtimeState['regions'][$regionKey]['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } return $repairs; } function wayfarerApplyRuntimeTickEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectTickEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection['branch_index'], 'applied' => false, 'error' => $selection['error'], ]; } /* * No conditional branch matched and there was no OTHERWISE. * This is a valid "do nothing this tick" result. */ if (!$selection['matched']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection['branch_index']; $operation = strtolower( trim( (string)( $branch['operation'] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; /* * Explicit No Change: * the branch DID match, so this Tick Effect stops here, but state does * not mutate. */ if ($operation === 'none') { $current = wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $type, $targetId ); return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'amount' => null, 'before' => $current['found'] ? $current['value'] : null, 'after' => $current['found'] ? $current['value'] : null, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'applied' => false, 'no_change' => true, 'error' => '', ]; } /* WAYFARER_TICK_EFFECT_RANDOM_RUNTIME_V58 */ $resolvedValue = wayfarerRuntimeRollEffectValue( $branch ); if (!$resolvedValue['valid']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'applied' => false, 'error' => $resolvedValue['error'], ]; } /* * WAYFARER_BIDIRECTIONAL_TRAVEL_TICK_EFFECT_V71 * * Tick movement is relative to the player's current journey direction. * Only Tick Effects are reversed here. One-time narrative Effects remain * literal state changes, so an authored "Subtract 3 position" effect does * not unexpectedly change meaning because the player is traveling back. */ if ( $scope === 'navigation' && $type === 'position' && $targetId === 'position' && wayfarerRuntimeNavigationDirection( $runtimeState ) < 0 ) { if ($operation === 'add') { $operation = 'subtract'; } elseif ($operation === 'subtract') { $operation = 'add'; } } /* * Apply ONLY the selected result branch. The legacy effect-level * operation/value fields are deliberately ignored when branches exist. */ $resolvedEffect = $effect; $resolvedEffect['operation'] = $operation; $resolvedEffect['value'] = $resolvedValue['value']; $report = wayfarerApplyRuntimeEffect( $runtimeState, $resolvedEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; $report['value_mode'] = $resolvedValue['mode']; $report['rolled_value'] = $resolvedValue['value']; if ($resolvedValue['mode'] === 'random') { $report['random_min'] = $resolvedValue['min']; $report['random_max'] = $resolvedValue['max']; } return $report; } /* |-------------------------------------------------------------------------- | Party aggregate Effect helpers | WAYFARER_PARTY_TARGETS_RUNTIME_V85 |-------------------------------------------------------------------------- */ function wayfarerRuntimePartyNormalizeTraitName( string $value ): string { $value = trim( preg_replace( '/\\s+/u', ' ', $value ) ?? $value ); if (function_exists('mb_strtolower')) { return mb_strtolower( $value, 'UTF-8' ); } return strtolower($value); } function wayfarerRuntimePartyDecodeTraitTargetId( string $targetId ): ?string { $prefix = 'party_name_'; if ( strpos( $targetId, $prefix ) !== 0 ) { return null; } $hex = substr( $targetId, strlen($prefix) ); if ( $hex === '' || strlen($hex) % 2 !== 0 || preg_match( '/^[0-9a-f]+$/i', $hex ) !== 1 ) { return null; } $decoded = hex2bin($hex); return $decoded === false ? null : wayfarerRuntimePartyNormalizeTraitName( $decoded ); } function wayfarerRuntimePartyFindTraitDefinition( array $definitions, string $normalizedName ): ?array { $match = null; foreach ( $definitions as $definitionKey => $definition ) { if (!is_array($definition)) { continue; } $name = wayfarerRuntimePartyNormalizeTraitName( (string)( $definition['name'] ?? '' ) ); if ($name !== $normalizedName) { continue; } if ($match !== null) { return null; } $traitId = trim( (string)( $definition['_runtime_id'] ?? $definition['id'] ?? $definitionKey ) ); if ($traitId === '') { return null; } $match = [ 'id' => $traitId, 'definition' => $definition, ]; } return $match; } function wayfarerApplyRuntimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); $operation = strtolower( trim( (string)( $effect['operation'] ?? '' ) ) ); $effectValue = $effect['value'] ?? 0; // ------------------------------------------------------------ // PARTY-MEMBER STATUS // WAYFARER_STATUS_EFFECT_RUNTIME_V87 // ------------------------------------------------------------ if ( $type === 'status' && preg_match('/^[1-9][0-9]*$/', $targetId) === 1 ) { if ($operation !== 'set') { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Status Effects support Set only.', ]; } $active = wayfarerRuntimeStatusNormalizeBoolean( $effectValue ); if ($scope === 'party') { $results = []; $partyCount = 0; $firstError = ''; $players = isset($runtimeState['players']) && is_array($runtimeState['players']) ? $runtimeState['players'] : []; foreach ($players as $playerKey => $playerState) { if (!is_array($playerState)) { continue; } $name = trim( (string)( $playerState['name'] ?? '' ) ); if ($name === '') { continue; } $partyCount++; $memberScope = 'player:' . (string)$playerKey; $child = wayfarerApplyRuntimeEffect( $runtimeState, [ 'target_scope' => $memberScope, 'target_type' => 'status', 'target_id' => $targetId, 'operation' => 'set', 'value' => $active ? 1 : 0, ], $currentRegionId, $traitDefinitions ); $child['party_member_name'] = $name; $child['party_member_scope'] = $memberScope; $results[] = $child; if (!empty($child['error'])) { $firstError = (string)$child['error']; break; } } if ($firstError === '') { $entities = isset($runtimeState['entities']) && is_array($runtimeState['entities']) ? $runtimeState['entities'] : []; foreach ($entities as $entityKey => $entityState) { if (!is_array($entityState)) { continue; } $runtimeTraits = isset($entityState['traits']) && is_array($entityState['traits']) ? $entityState['traits'] : []; if (empty($runtimeTraits['sys_character_party_member'])) { continue; } $partyCount++; $memberScope = 'entity:' . (string)$entityKey; $child = wayfarerApplyRuntimeEffect( $runtimeState, [ 'target_scope' => $memberScope, 'target_type' => 'status', 'target_id' => $targetId, 'operation' => 'set', 'value' => $active ? 1 : 0, ], $currentRegionId, $traitDefinitions ); $child['party_member_scope'] = $memberScope; $results[] = $child; if (!empty($child['error'])) { $firstError = (string)$child['error']; break; } } } if ($firstError === '' && $partyCount === 0) { $firstError = 'There are no current party members.'; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $active ? 1 : 0, 'before' => null, 'after' => null, 'party_count' => $partyCount, 'eligible_count' => $partyCount, 'skipped_count' => 0, 'results' => $results, 'applied' => $firstError === '' && $partyCount > 0, 'error' => $firstError, ]; } if ( preg_match( '/^(?:player:[0-9]+|entity:[1-9][0-9]*)$/', $scope ) === 1 ) { $statusResult = wayfarerRuntimeSetStatus( $runtimeState, $scope, (int)$targetId, $active ); return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $active ? 1 : 0, 'before' => $statusResult['before'], 'after' => $statusResult['after'], 'started_tick' => $statusResult['started_tick'], 'applied' => $statusResult['applied'], 'error' => $statusResult['error'], ]; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Status Effect target scope is invalid.', ]; } // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $type === 'position' && $targetId === 'position' ) { if ( !isset($runtimeState['navigation']) || !is_array($runtimeState['navigation']) ) { $runtimeState['navigation'] = []; } $before = $runtimeState['navigation']['position'] ?? 0; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); $after = $before; if ( $result['applied'] && !is_numeric($result['value']) ) { $result['applied'] = false; $result['error'] = 'Navigation position must be numeric.'; } if ($result['applied']) { /* * Global navigation rule: * position may never be less than zero. */ $after = max( 0, $result['value'] ); $runtimeState['navigation']['position'] = $after; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $after, 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['game']) || !is_array($runtimeState['game']) ) { $runtimeState['game'] = []; } if ( !isset($runtimeState['game']['traits']) || !is_array($runtimeState['game']['traits']) ) { $runtimeState['game']['traits'] = []; } $before = $runtimeState['game']['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['game'][$targetId] ) && is_array( $traitDefinitions['game'][$targetId] ) ? $traitDefinitions['game'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); /* * Persist the normalized value even when a malformed operation fails * after an existing illegal value was repaired. */ if ( $result['normalized_before'] !== $before ) { $runtimeState['game']['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['game']['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // ALL PARTY MEMBERS - LOGICAL TRAIT BY NAME // WAYFARER_PARTY_TARGETS_RUNTIME_V85 // ------------------------------------------------------------ if ( $scope === 'party' && $type === 'trait' && $targetId !== '' ) { $normalizedName = wayfarerRuntimePartyDecodeTraitTargetId( $targetId ); if ($normalizedName === null) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Party Trait target is invalid.', ]; } $results = []; $eligibleCount = 0; $partyCount = 0; $firstError = ''; $players = isset($runtimeState['players']) && is_array($runtimeState['players']) ? $runtimeState['players'] : []; $playerDefinitions = isset($traitDefinitions['player']) && is_array($traitDefinitions['player']) ? $traitDefinitions['player'] : []; $playerTrait = wayfarerRuntimePartyFindTraitDefinition( $playerDefinitions, $normalizedName ); foreach ($players as $playerKey => $playerState) { if (!is_array($playerState)) { continue; } $name = trim( (string)( $playerState['name'] ?? '' ) ); if ($name === '') { continue; } $partyCount++; if (!is_array($playerTrait)) { continue; } $actualTargetId = (string)$playerTrait['id']; $runtimeTraits = isset($playerState['traits']) && is_array($playerState['traits']) ? $playerState['traits'] : []; if ( !array_key_exists( $actualTargetId, $runtimeTraits ) ) { continue; } $eligibleCount++; $memberScope = 'player:' . (string)$playerKey; $childEffect = [ 'target_scope' => $memberScope, 'target_type' => 'trait', 'target_id' => $actualTargetId, 'operation' => $operation, 'value' => $effectValue, ]; $child = wayfarerApplyRuntimeEffect( $runtimeState, $childEffect, $currentRegionId, $traitDefinitions ); $child['party_member_name'] = $name; $child['party_member_scope'] = $memberScope; $results[] = $child; if (!empty($child['error'])) { $firstError = (string)$child['error']; break; } } if ($firstError === '') { $entities = isset($runtimeState['entities']) && is_array($runtimeState['entities']) ? $runtimeState['entities'] : []; foreach ($entities as $entityKey => $entityState) { if (!is_array($entityState)) { continue; } $runtimeTraits = isset($entityState['traits']) && is_array($entityState['traits']) ? $entityState['traits'] : []; if ( empty( $runtimeTraits[ 'sys_character_party_member' ] ) ) { continue; } $partyCount++; $definitions = isset( $traitDefinitions['entities'][ (string)$entityKey ] ) && is_array( $traitDefinitions['entities'][ (string)$entityKey ] ) ? $traitDefinitions['entities'][ (string)$entityKey ] : []; $entityTrait = wayfarerRuntimePartyFindTraitDefinition( $definitions, $normalizedName ); if (!is_array($entityTrait)) { continue; } $actualTargetId = (string)$entityTrait['id']; if ( !array_key_exists( $actualTargetId, $runtimeTraits ) ) { continue; } $eligibleCount++; $memberScope = 'entity:' . (string)$entityKey; $childEffect = [ 'target_scope' => $memberScope, 'target_type' => 'trait', 'target_id' => $actualTargetId, 'operation' => $operation, 'value' => $effectValue, ]; $child = wayfarerApplyRuntimeEffect( $runtimeState, $childEffect, $currentRegionId, $traitDefinitions ); $child['party_member_scope'] = $memberScope; $results[] = $child; if (!empty($child['error'])) { $firstError = (string)$child['error']; break; } } } if ( $firstError === '' && $eligibleCount === 0 ) { $firstError = 'No current party member exposes the selected Trait.'; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'party_count' => $partyCount, 'eligible_count' => $eligibleCount, 'skipped_count' => max( 0, $partyCount - $eligibleCount ), 'results' => $results, 'applied' => $firstError === '' && $eligibleCount > 0, 'error' => $firstError, ]; } // ------------------------------------------------------------ // ITEM ENTITY QUANTITY // WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 // ------------------------------------------------------------ if ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityScopeMatch ) === 1 && ( (string)( $effect['target_system_key'] ?? '' ) === 'quantity' || $targetId === 'sys_item_quantity' ) ) { $entityKey = (string)( (int)$entityScopeMatch[1] ); if ( !isset( $runtimeState['entities'][$entityKey] ) || !is_array( $runtimeState['entities'][$entityKey] ) || !isset( $runtimeState['entities'][$entityKey]['traits'] ) || !is_array( $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Entity runtime state was not initialized.', ]; } if ( !array_key_exists( $targetId, $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Quantity runtime state was not found.', ]; } $before = $runtimeState['entities'][$entityKey]['traits'][$targetId]; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); if ( !$result['applied'] || !is_numeric( $result['value'] ?? null ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $before, 'applied' => false, 'error' => $result['error'] !== '' ? $result['error'] : 'Item Quantity must remain numeric.', ]; } $requestedAfter = wayfarerNormalizeRuntimeNumber( (float)$result['value'] ); /* * Quantity is globally non-negative. */ $after = wayfarerNormalizeRuntimeNumber( max( 0.0, (float)$requestedAfter ) ); $runtimeState['entities'][$entityKey]['traits'][$targetId] = $after; /* * Discovery is persistent. Receiving any positive starting quantity * discovers the Item; setting it back to zero never undiscoveres it. */ if ( (float)$after > 0 ) { $runtimeState['entities'][$entityKey]['discovered'] = true; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_system_key' => 'quantity', 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'requested_after' => $requestedAfter, 'after' => $after, 'clamped' => (float)$after !== (float)$requestedAfter, 'min' => 0, 'max' => null, 'applied' => true, 'error' => '', ]; } // ------------------------------------------------------------ // SPECIFIC PLAYER / REGION / LOCATION / ENTITY TRAITS // WAYFARER_RUNTIME_UNIVERSAL_EFFECT_TARGETS_V56 // ------------------------------------------------------------ $stateBucket = null; $stateKey = null; $definition = null; if ( $type === 'trait' && $targetId !== '' && preg_match( '/^player:([0-9]+)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'players'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['player'][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^region:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'regions'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['regions'][$stateKey][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^location:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'locations'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['locations'][$stateKey][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 && $targetId !== 'sys_item_quantity' ) { $stateBucket = 'entities'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['entities'][$stateKey][$targetId] ?? null; } if ( $stateBucket !== null && $stateKey !== null ) { if ( !isset($runtimeState[$stateBucket][$stateKey]) || !is_array($runtimeState[$stateBucket][$stateKey]) || !isset($runtimeState[$stateBucket][$stateKey]['traits']) || !is_array($runtimeState[$stateBucket][$stateKey]['traits']) || !array_key_exists( $targetId, $runtimeState[$stateBucket][$stateKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Runtime Trait target was not found.', ]; } $before = $runtimeState[$stateBucket][$stateKey]['traits'][$targetId]; /* * Party Membership is mutable runtime state, but still a binary * Wayfarer system property. The 0/1 rule is enforced internally and is * never exposed as a separate Target/max metadata value. */ if ( $stateBucket === 'entities' && $targetId === 'sys_character_party_member' ) { $operationResult = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); if ( !$operationResult['applied'] || !is_numeric( $operationResult['value'] ?? null ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $before, 'applied' => false, 'error' => $operationResult['error'] !== '' ? $operationResult['error'] : 'Party Membership requires a numeric 0/1 result.', ]; } $after = (float)$operationResult['value'] > 0 ? 1 : 0; $runtimeState[$stateBucket][$stateKey]['traits'][$targetId] = $after; return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_system_key' => 'party_member', 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $after, 'applied' => true, 'error' => '', ]; } $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, is_array($definition) ? $definition : null ); if ($result['applied']) { $runtimeState[$stateBucket][$stateKey]['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // PLAYER TRAIT // WAYFARER_STARTING_CHOICE_PLAYER_TARGETS_V53 // ------------------------------------------------------------ if ( $scope === 'player' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['players']) || !is_array($runtimeState['players']) ) { $runtimeState['players'] = []; } $selector = strtolower( trim( (string)( $effect['target_selector'] ?? '' ) ) ); $targetKeys = []; if ($selector === 'all') { foreach ( $runtimeState['players'] as $playerKey => $playerState ) { if (is_array($playerState)) { $targetKeys[] = (string)$playerKey; } } } elseif ( $selector === 'slot' && array_key_exists( 'target_slot', $effect ) ) { $slot = max( 0, (int)$effect['target_slot'] ); $targetKeys[] = (string)$slot; } else { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect requires an All Players or Player Slot target.', ]; } if (empty($targetKeys)) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect did not resolve any Player Characters.', ]; } $definition = isset( $traitDefinitions['player'][$targetId] ) && is_array( $traitDefinitions['player'][$targetId] ) ? $traitDefinitions['player'][$targetId] : null; $results = []; $firstError = ''; foreach ($targetKeys as $playerKey) { if ( !isset( $runtimeState['players'][$playerKey] ) || !is_array( $runtimeState['players'][$playerKey] ) ) { $firstError = 'Player Character slot ' . ((int)$playerKey + 1) . ' does not exist in runtime state.'; $results[$playerKey] = [ 'applied' => false, 'error' => $firstError, ]; break; } if ( !isset( $runtimeState['players'][$playerKey]['traits'] ) || !is_array( $runtimeState['players'][$playerKey]['traits'] ) ) { $runtimeState['players'][$playerKey]['traits'] = []; } $before = $runtimeState['players'][$playerKey]['traits'][$targetId] ?? 0; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['after']; } $results[$playerKey] = [ 'slot' => (int)$playerKey, 'name' => (string)( $runtimeState['players'][$playerKey]['name'] ?? ( 'Player ' . ((int)$playerKey + 1) ) ), 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; if ( !$result['applied'] || $result['error'] !== '' ) { $firstError = $result['error'] !== '' ? $result['error'] : 'Player Trait effect could not be applied.'; break; } } $allApplied = $firstError === ''; return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_selector' => $selector, 'target_slot' => $selector === 'slot' ? (int)( $effect['target_slot'] ?? 0 ) : null, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'results' => $results, 'applied' => $allApplied, 'error' => $firstError, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $type === 'trait' && $targetId !== '' ) { $regionKey = (string)$currentRegionId; if ( !isset($runtimeState['regions']) || !is_array($runtimeState['regions']) ) { $runtimeState['regions'] = []; } if ( !isset($runtimeState['regions'][$regionKey]) || !is_array($runtimeState['regions'][$regionKey]) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset($runtimeState['regions'][$regionKey]['traits']) || !is_array($runtimeState['regions'][$regionKey]['traits']) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } $before = $runtimeState['regions'][$regionKey]['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['region'][$targetId] ) && is_array( $traitDefinitions['region'][$targetId] ) ? $traitDefinitions['region'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Unsupported runtime target.', ]; } /* |-------------------------------------------------------------------------- | Game Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_GAME_TICK_EFFECT_RUNTIME_V33 | | Game Tick Effects execute once on every successful runtime tick before | the active Region's Tick Effects. This creates a deterministic layering: | | Game rules -> Region rules | */ function wayfarerApplyGameTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { if ($gameId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active game is available for Game Tick Effects.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { $reports = [ [ 'applied' => false, 'error' => 'Game ' . $gameId . ' was not found.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $gameConfig = []; if ( $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, [] ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $gameEffects = $gameConfig['tick_effects'] ?? []; if (!is_array($gameEffects)) { $gameEffects = []; } $reports = []; foreach ($gameEffects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Game Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; $runtimeState['runtime']['last_game_trait_bound_repairs'] = $boundRepairs; return $reports; } /* |-------------------------------------------------------------------------- | Generalized one-time Effect runtime | WAYFARER_GENERALIZED_EFFECT_RUNTIME_V43 |-------------------------------------------------------------------------- | | Conditions use the same shared evaluator as Tick Effect branches. | Random values are resolved exactly once, immediately before the selected | result mutates runtime state. | */ function wayfarerRuntimeEffectBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * v42 compatibility: one Effect was one unconditional operation/value. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $branch = [ 'conditions' => null, 'operation' => $effect['operation'] ?? 'add', 'value_mode' => $effect['value_mode'] ?? 'fixed', ]; if ( strtolower( trim( (string)( $branch[ 'value_mode' ] ) ) ) === 'random' ) { $branch['value_min'] = $effect['value_min'] ?? 0; $branch['value_max'] = $effect['value_max'] ?? 0; } else { $branch['value'] = $effect['value'] ?? 0; } return [ $branch ]; } return []; } function wayfarerRuntimeSelectEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; $validation = wayfarerConditionValidateNode( $conditions ); if ( !$validation['valid'] ) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } function wayfarerRuntimeRollEffectValue( array $branch ): array { $mode = strtolower( trim( (string)( $branch[ 'value_mode' ] ?? 'fixed' ) ) ); if ($mode !== 'random') { $value = $branch['value'] ?? null; /* WAYFARER_FIXED_SCALAR_EFFECT_RUNTIME_V58 */ return [ 'valid' => true, 'mode' => 'fixed', 'value' => is_numeric($value) ? wayfarerNormalizeRuntimeNumber( (float)$value ) : $value, 'min' => null, 'max' => null, 'error' => '', ]; } $minimum = $branch['value_min'] ?? null; $maximum = $branch['value_max'] ?? null; if ( !is_numeric($minimum) || !is_numeric($maximum) ) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => $minimum, 'max' => $maximum, 'error' => 'Random Effect requires numeric Min and Max values.', ]; } $min = (float)$minimum; $max = (float)$maximum; if ($min > $max) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => 'Random Effect Min cannot be greater than Max.', ]; } if ( floor($min) === $min && floor($max) === $max ) { $rolled = random_int( (int)$min, (int)$max ); } else { /* * Continuous decimal range. Six decimal places of entropy are more * than sufficient for authored simulation values and preserve generic * numeric Min/Max support without requiring authored precision metadata. */ $fraction = random_int( 0, 1000000 ) / 1000000; $rolled = $min + (($max - $min) * $fraction); } return [ 'valid' => true, 'mode' => 'random', 'value' => wayfarerNormalizeRuntimeNumber( (float)$rolled ), 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => '', ]; } function wayfarerApplyRuntimeOneTimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect[ 'target_scope' ] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect[ 'target_type' ] ?? '' ) ) ); $targetId = trim( (string)( $effect[ 'target_id' ] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection[ 'branch_index' ], 'applied' => false, 'error' => $selection[ 'error' ], ]; } if ( !$selection['matched'] ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection[ 'branch_index' ]; $operation = strtolower( trim( (string)( $branch[ 'operation' ] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; if ($operation === 'none') { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => null, 'rolled_value' => null, 'applied' => false, 'error' => '', ]; } $resolved = wayfarerRuntimeRollEffectValue( $branch ); if (!$resolved['valid']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => $resolved[ 'mode' ], 'rolled_value' => null, 'random_min' => $resolved[ 'min' ], 'random_max' => $resolved[ 'max' ], 'applied' => false, 'error' => $resolved[ 'error' ], ]; } $concreteEffect = [ 'target_scope' => $scope, 'target_type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'value' => $resolved[ 'value' ], ]; $report = wayfarerApplyRuntimeEffect( $runtimeState, $concreteEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; $report['value_mode'] = $resolved[ 'mode' ]; $report['rolled_value'] = $resolved[ 'value' ]; if ( $resolved['mode'] === 'random' ) { $report['random_min'] = $resolved[ 'min' ]; $report['random_max'] = $resolved[ 'max' ]; } return $report; } /* |-------------------------------------------------------------------------- | One-time Effect player feedback | WAYFARER_ONE_TIME_EFFECT_FEEDBACK_V70 |-------------------------------------------------------------------------- | | Change events are derived from the Effect report AFTER runtime mutation. | Therefore Random rolls and min/max/non-negative clamps are already reflected | in before/after, and the player sees the quantity that truly changed. | | This layer is intentionally called only by narrative one-time Effect paths. | Tick Effects remain silent. | */ function wayfarerRuntimeFeedbackFindTrait( array $config, string $targetId, string $collection = 'traits' ): ?array { $traits = isset($config[$collection]) && is_array($config[$collection]) ? $config[$collection] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } $id = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($id === $targetId) { return $trait; } } return null; } function wayfarerRuntimeFeedbackTraitIsPlayerFacing(array $trait): bool { $feedback = strtolower( trim( (string)( $trait['feedback'] ?? '' ) ) ); if ($feedback === 'hide') { return false; } if ($feedback === 'show') { return true; } $display = isset($trait['display']) && is_array($trait['display']) ? $trait['display'] : []; $surfaces = isset($display['surfaces']) && is_array($display['surfaces']) ? $display['surfaces'] : []; return !empty($surfaces); } function wayfarerRuntimeFeedbackTraitMetadata( array $trait, string $fallbackLabel, string $ownerLabel = '' ): ?array { if (!wayfarerRuntimeFeedbackTraitIsPlayerFacing($trait)) { return null; } $label = trim( (string)( $trait['name'] ?? $fallbackLabel ) ); if ($label === '') { $label = $fallbackLabel; } $type = strtolower( trim( (string)( $trait['type'] ?? 'number' ) ) ); if ($type === 'binary') { $type = 'boolean'; } return [ 'kind' => 'trait', 'label' => $label, 'owner_label' => $ownerLabel, 'data_type' => $type, ]; } function wayfarerRuntimeFeedbackTargetMetadata( PDO $pdo, int $gameId, array $runtimeState, array $report ): ?array { $scope = strtolower( trim( (string)( $report['scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $report['type'] ?? '' ) ) ); $targetId = trim( (string)( $report['target_id'] ?? '' ) ); if ( $scope === '' || $targetId === '' || !in_array($type, ['trait', 'status'], true) ) { return null; } /* WAYFARER_STATUS_EFFECT_FEEDBACK_V87 */ if ( $type === 'status' && preg_match('/^[1-9][0-9]*$/', $targetId) === 1 ) { $definition = wayfarerRuntimeStatusDefinition( $pdo, $gameId, (int)$targetId, true ); if ( !is_array($definition) || empty($definition['show_to_player']) ) { return null; } $ownerLabel = ''; if ( preg_match( '/^player:([0-9]+)$/', $scope, $playerStatusMatch ) === 1 ) { $slot = (string)((int)$playerStatusMatch[1]); $ownerLabel = trim( (string)( $runtimeState['players'][$slot]['name'] ?? ('Player ' . ((int)$slot + 1)) ) ); } elseif ( preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityStatusMatch ) === 1 ) { $entityId = (int)$entityStatusMatch[1]; $stmt = $pdo->prepare( " SELECT name FROM wf_entities WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $entityId, $gameId, ]); $name = $stmt->fetchColumn(); if ($name !== false) { $ownerLabel = trim((string)$name); } } return [ 'kind' => 'status', 'label' => (string)($definition['name'] ?? ('Status ' . $targetId)), 'owner_label' => $ownerLabel, 'data_type' => 'boolean', ]; } /* Navigation is deliberately silent even for one-time Effects. */ if ($scope === 'navigation') { return null; } /* -------------------------------------------------------------- * Entity state: Quantity and Party Membership are intrinsically visible. * ----------------------------------------------------------- */ if ( preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityMatch ) === 1 ) { $entityId = (int)$entityMatch[1]; $stmt = $pdo->prepare( " SELECT name, entity_type, config FROM wf_entities WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $entityId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $entityName = trim( (string)( $row['name'] ?? ('Entity ' . $entityId) ) ); if ($entityName === '') { $entityName = 'Entity ' . $entityId; } $systemKey = strtolower( trim( (string)( $report['target_system_key'] ?? '' ) ) ); if ( $targetId === 'sys_item_quantity' || $systemKey === 'quantity' ) { return [ 'kind' => 'item_quantity', 'label' => $entityName, 'owner_label' => $entityName, 'data_type' => 'number', ]; } if ( $targetId === 'sys_character_party_member' || $systemKey === 'party_member' ) { return [ 'kind' => 'party_membership', 'label' => $entityName, 'owner_label' => $entityName, 'data_type' => 'boolean', ]; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, $entityName ) : null; } /* -------------------------------------------------------------- * Game Trait. * ----------------------------------------------------------- */ if ($scope === 'game') { $stmt = $pdo->prepare( 'SELECT config FROM wf_games WHERE id = ? LIMIT 1' ); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); if ($raw === false) { return null; } $config = wayfarerRuntimeDecodeTraitConfig($raw); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, 'Game' ) : null; } /* -------------------------------------------------------------- * Specific Region Trait, plus legacy current-Region compatibility. * ----------------------------------------------------------- */ $regionId = 0; if ( preg_match( '/^region:([1-9][0-9]*)$/', $scope, $regionMatch ) === 1 ) { $regionId = (int)$regionMatch[1]; } elseif ($scope === 'region') { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); } if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, trim((string)($row['name'] ?? 'Region')) ) : null; } /* -------------------------------------------------------------- * Location Trait. * ----------------------------------------------------------- */ if ( preg_match( '/^location:([1-9][0-9]*)$/', $scope, $locationMatch ) === 1 ) { $locationId = (int)$locationMatch[1]; $stmt = $pdo->prepare( " SELECT l.name, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE l.id = ? AND r.game_id = ? LIMIT 1 " ); $stmt->execute([ $locationId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, trim((string)($row['name'] ?? 'Location')) ) : null; } /* -------------------------------------------------------------- * Specific Player Character Trait. * ----------------------------------------------------------- */ if ( preg_match( '/^player:([0-9]+)$/', $scope, $playerMatch ) === 1 ) { $slot = (int)$playerMatch[1]; $stmt = $pdo->prepare( 'SELECT config FROM wf_games WHERE id = ? LIMIT 1' ); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); if ($raw === false) { return null; } $config = wayfarerRuntimeDecodeTraitConfig($raw); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId, 'player_traits' ); if (!is_array($trait)) { return null; } $playerName = trim( (string)( $runtimeState['players'][(string)$slot]['name'] ?? ('Player ' . ($slot + 1)) ) ); return wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, $playerName ); } return null; } function wayfarerRuntimeBuildEffectChangeEvents( PDO $pdo, int $gameId, array $runtimeState, array $reports ): array { $changes = []; foreach ($reports as $report) { if ( !is_array($report) || empty($report['applied']) ) { continue; } /* WAYFARER_PARTY_TARGETS_RUNTIME_V85 */ if ( strtolower( trim( (string)( $report['scope'] ?? '' ) ) ) === 'party' && isset($report['results']) && is_array($report['results']) ) { foreach ( wayfarerRuntimeBuildEffectChangeEvents( $pdo, $gameId, $runtimeState, $report['results'] ) as $partyChange ) { $changes[] = $partyChange; } continue; } $metadata = wayfarerRuntimeFeedbackTargetMetadata( $pdo, $gameId, $runtimeState, $report ); if (!is_array($metadata)) { continue; } $before = $report['before'] ?? null; $after = $report['after'] ?? null; $kind = (string)($metadata['kind'] ?? 'trait'); $label = trim((string)($metadata['label'] ?? '')); $ownerLabel = trim((string)($metadata['owner_label'] ?? '')); if ($label === '') { continue; } if ($kind === 'status') { if (!is_numeric($before) || !is_numeric($after)) { continue; } $beforeActive = (float)$before > 0; $afterActive = (float)$after > 0; if ($beforeActive === $afterActive) { continue; } $displayLabel = $ownerLabel !== '' ? $label . ' — ' . $ownerLabel : $label; $changes[] = [ 'kind' => 'status', 'direction' => $afterActive ? 'gain' : 'loss', 'label' => $label, 'owner_label' => $ownerLabel, 'before' => $beforeActive ? 1 : 0, 'after' => $afterActive ? 1 : 0, 'delta' => $afterActive ? 1 : -1, 'text' => $displayLabel . ': ' . ($afterActive ? 'Active' : 'Inactive'), 'aria_text' => ($ownerLabel !== '' ? $ownerLabel . ': ' . $label : $label) . ' is now ' . ($afterActive ? 'active.' : 'inactive.'), ]; continue; } if ($kind === 'party_membership') { if (!is_numeric($before) || !is_numeric($after)) { continue; } $beforeMember = (float)$before > 0; $afterMember = (float)$after > 0; if ($beforeMember === $afterMember) { continue; } $joined = $afterMember; $changes[] = [ 'kind' => 'party_membership', 'direction' => $joined ? 'gain' : 'loss', 'label' => $label, 'before' => $beforeMember ? 1 : 0, 'after' => $afterMember ? 1 : 0, 'delta' => $joined ? 1 : -1, 'text' => $label . ($joined ? ' joined your party.' : ' left your party.'), 'aria_text' => $label . ($joined ? ' joined your party.' : ' left your party.'), ]; continue; } $dataType = strtolower( trim((string)($metadata['data_type'] ?? 'number')) ); if ($dataType === 'boolean') { if (!is_numeric($before) || !is_numeric($after)) { continue; } $beforeBool = (float)$before > 0; $afterBool = (float)$after > 0; if ($beforeBool === $afterBool) { continue; } $displayLabel = $ownerLabel !== '' && $ownerLabel !== $label ? $label . ' — ' . $ownerLabel : $label; $changes[] = [ 'kind' => $kind, 'direction' => $afterBool ? 'gain' : 'loss', 'label' => $label, 'owner_label' => $ownerLabel, 'before' => $beforeBool ? 1 : 0, 'after' => $afterBool ? 1 : 0, 'delta' => $afterBool ? 1 : -1, 'text' => $displayLabel . ': ' . ($afterBool ? 'On' : 'Off'), 'aria_text' => ($ownerLabel !== '' && $ownerLabel !== $label ? $ownerLabel . ': ' . $label : $label) . ' is now ' . ($afterBool ? 'on.' : 'off.'), ]; continue; } if (!is_numeric($before) || !is_numeric($after)) { continue; } $deltaFloat = (float)$after - (float)$before; if (abs($deltaFloat) < 0.000000000001) { continue; } $delta = wayfarerNormalizeRuntimeNumber($deltaFloat); $amount = wayfarerNormalizeRuntimeNumber(abs($deltaFloat)); $gain = $deltaFloat > 0; $displayLabel = $ownerLabel !== '' && $ownerLabel !== $label && $kind !== 'item_quantity' ? $label . ' — ' . $ownerLabel : $label; $text = ($gain ? '+' : '-') . (string)$amount . ' ' . $displayLabel; if ($kind === 'item_quantity') { $ariaText = ($gain ? 'Gained ' : 'Lost ') . (string)$amount . ' ' . $label . '.'; } else { $ariaSubject = $ownerLabel !== '' && $ownerLabel !== $label ? $ownerLabel . ': ' . $label : $label; $ariaText = $ariaSubject . ($gain ? ' increased by ' : ' decreased by ') . (string)$amount . '.'; } $changes[] = [ 'kind' => $kind, 'direction' => $gain ? 'gain' : 'loss', 'label' => $label, 'owner_label' => $ownerLabel, 'before' => $before, 'after' => $after, 'delta' => $delta, 'text' => $text, 'aria_text' => $ariaText, ]; } return $changes; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECT_RUNTIME_V42 | | These are event Effects, not Tick Effects. The caller decides that a | Location has been triggered and invokes this function exactly once for that | trigger. Effects execute top-to-bottom against current runtime state. | */ function wayfarerApplyLocationEffects( PDO $pdo, int $gameId, array $location, array &$runtimeState ): array { $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeTraitConfig( $location['config'] ?? null ); $effects = $locationConfig['effects'] ?? []; if (!is_array($effects)) { $effects = []; } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false ) { $regionConfig = wayfarerRuntimeDecodeTraitConfig( $rawRegionConfig ); } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $reports = []; /* WAYFARER_LOCATION_STORYLET_TRANSITIONS_V102 */ $deferredStoryletEffects = []; /* * Phase 1: every ordinary Effect resolves first, regardless of where a * Storylet row appears in authored order. */ foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Effect.', ]; continue; } if ( (string)($effect['target_scope'] ?? '') === 'storylet' && (string)($effect['target_type'] ?? '') === 'transition' ) { $deferredStoryletEffects[] = $effect; continue; } $reports[] = wayfarerApplyRuntimeOneTimeEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } /* * Phase 2: evaluate Storylet transitions against the fully-updated state. */ $pendingStoryletId = 0; foreach ($deferredStoryletEffects as $effect) { $selection = wayfarerRuntimeSelectEffectBranch( $runtimeState, $effect, $currentRegionId ); if (($selection['error'] ?? '') !== '') { throw new RuntimeException( 'Location Storylet Continue To Effect failed: ' . (string)$selection['error'] ); } $matched = !empty($selection['matched']); $branch = is_array($selection['branch'] ?? null) ? $selection['branch'] : []; $operation = strtolower( trim( (string)($branch['operation'] ?? 'none') ) ); $targetStoryletId = (int)($effect['target_id'] ?? 0); $applied = $matched && $operation === 'continue'; if ($applied) { if ($targetStoryletId <= 0) { throw new RuntimeException( 'Location Storylet Continue To Effect has an invalid destination.' ); } if ( $pendingStoryletId > 0 && $pendingStoryletId !== $targetStoryletId ) { throw new RuntimeException( 'More than one Location Storylet Continue To Effect matched.' ); } $pendingStoryletId = $targetStoryletId; } $reports[] = [ 'scope' => 'storylet', 'type' => 'transition', 'target_id' => (string)$targetStoryletId, 'operation' => $operation, 'applied' => $applied, 'matched' => $matched, 'branch_index' => $selection['branch_index'] ?? null, 'error' => '', ]; } if ($pendingStoryletId > 0) { if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['pending_storylet_transition'] = $pendingStoryletId; $runtimeState['runtime']['pending_storylet_source_context'] = [ 'source_type' => 'location', 'location_id' => $locationId, 'region_id' => $currentRegionId, 'position' => $runtimeState['navigation']['position'] ?? null, 'tick' => (int)($runtimeState['runtime']['tick'] ?? 0), ]; } if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_location_effects'] = [ 'location_id' => $locationId, 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), 'reports' => $reports, /* WAYFARER_LOCATION_EFFECT_FEEDBACK_V70 */ 'changes' => wayfarerRuntimeBuildEffectChangeEvents( $pdo, $gameId, $runtimeState, $reports ), 'bound_repairs' => $boundRepairs, ]; return $reports; } /* |-------------------------------------------------------------------------- | Current Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECT_RUNTIME_V35 | | Linear Regions resolve a current Location by exact authored Position, | matching the player-scene resolver. Location Tick Effects run after Region | Tick Effects, so movement performed by the Region can cause arrival effects | at the newly reached Location on the same tick. | */ function wayfarerRuntimeFindCurrentLocation( PDO $pdo, int $gameId, array $runtimeState ): ?array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ( $gameId <= 0 || $regionId <= 0 ) { return null; } $position = $runtimeState['navigation']['position'] ?? null; if (!is_numeric($position)) { return null; } $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($locations as $location) { $config = []; if ( !empty( $location['config'] ) ) { $decoded = json_decode( (string)$location['config'], true ); if (is_array($decoded)) { $config = $decoded; } } if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } if ( (float)$config['position'] === (float)$position ) { $location['_runtime_config'] = $config; /* * WAYFARER_LOCATION_CONDITIONS_CURRENT_V45 */ if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } return $location; } } return null; } function wayfarerApplyCurrentLocationTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } /* * Being between authored Locations is normal. Record an empty result * rather than treating the absence of a Location as an error. */ if (!$location) { $runtimeState['runtime']['last_location_id'] = null; $runtimeState['runtime']['last_location_tick_effects'] = []; return []; } $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : []; $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); /* * Location effects may target Current Region Traits, so load the active * Region's Trait definitions rather than the Location's authored Traits. */ $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false && $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $locationEffects = $locationConfig['tick_effects'] ?? []; if (!is_array($locationEffects)) { $locationEffects = []; } $reports = []; foreach ( $locationEffects as $effect ) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } $runtimeState['runtime']['last_location_id'] = $locationId; $runtimeState['runtime']['last_location_tick_effects'] = $reports; $runtimeState['runtime']['last_location_trait_bound_repairs'] = $boundRepairs; return $reports; } /* * WAYFARER_GAME_TRAVEL_MODIFIERS_RUNTIME_V73 * * Game Travel Modifiers are evaluated only when dedicated Linear Travel is * about to move the player. They do not mutate state by themselves and are * intentionally silent in normal Effect feedback. */ function wayfarerRuntimeEvaluateGameTravelModifiers( PDO $pdo, int $gameId, int $currentRegionId, array &$runtimeState ): array { $result = [ 'adjustment' => 0, 'matched' => [], ]; if ($gameId <= 0) { return $result; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig === false || $rawConfig === null || $rawConfig === '' ) { return $result; } $gameConfig = json_decode( (string)$rawConfig, true ); if (!is_array($gameConfig)) { return $result; } $modifiers = $gameConfig['travel']['modifiers'] ?? []; if (!is_array($modifiers)) { return $result; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; $adjustment = 0.0; foreach ( array_values($modifiers) as $modifier ) { if (!is_array($modifier)) { continue; } $operation = strtolower( trim( (string)( $modifier['operation'] ?? '' ) ) ); $value = $modifier['value'] ?? null; if ( !in_array( $operation, ['add', 'subtract'], true ) || !is_numeric($value) || (float)$value <= 0 ) { continue; } if ( !wayfarerConditionsPass( $modifier['conditions'] ?? null, $resolver ) ) { continue; } $delta = $operation === 'subtract' ? -(float)$value : (float)$value; $adjustment += $delta; $result['matched'][] = [ 'id' => (string)( $modifier['id'] ?? '' ), 'name' => (string)( $modifier['name'] ?? 'Travel Modifier' ), 'operation' => $operation, 'value' => wayfarerNormalizeRuntimeNumber( (float)$value ), 'adjustment' => wayfarerNormalizeRuntimeNumber( $delta ), ]; } $result['adjustment'] = wayfarerNormalizeRuntimeNumber( $adjustment ); return $result; } function wayfarerApplyCurrentRegionTickEffects( PDO $pdo, array &$runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ($regionId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active navigation region.', ] ]; /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Reports are returned for the current request, not persisted. */ return $reports; } $stmt = $pdo->prepare( " SELECT id, game_id, config FROM wf_regions WHERE id = ? LIMIT 1 " ); $stmt->execute([ $regionId ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { $reports = [ [ 'applied' => false, 'error' => 'Active Region ' . $regionId . ' was not found.', ] ]; /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Reports are returned for the current request, not persisted. */ return $reports; } $config = []; if (!empty($region['config'])) { $decoded = json_decode( $region['config'], true ); if (is_array($decoded)) { $config = $decoded; } } /* WAYFARER_LINEAR_TRAVEL_TICK_PIPELINE_V72 */ $dedicatedTravel = wayfarerRuntimeDedicatedLinearTravel( $config ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, (int)( $region['game_id'] ?? 0 ), $config ); /* * Enforce the invariant before any branch condition is evaluated. * This also repairs state produced by older runtime versions. */ $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $regionId ); $effects = $config['tick_effects'] ?? []; if (!is_array($effects)) { $effects = []; } $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed tick effect.', ]; continue; } /* * WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 * * A dedicated travel Region owns movement through config.travel. * Ignore only Region Tick Effects that target Navigation Position; * every other recurring Region Effect continues normally. */ if ( $dedicatedTravel !== null && strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ) === 'navigation' && strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ) === 'position' && (string)( $effect['target_id'] ?? '' ) === 'position' ) { $reports[] = [ 'scope' => 'navigation', 'type' => 'position', 'target_id' => 'position', 'applied' => false, 'ignored' => true, 'reason' => 'Dedicated Linear Travel owns Region movement.', 'error' => '', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $regionId, $traitDefinitions ); } /* WAYFARER_GAME_TRAVEL_MODIFIERS_RUNTIME_V73 */ if ($dedicatedTravel !== null) { $gameTravelModifierResult = wayfarerRuntimeEvaluateGameTravelModifiers( $pdo, (int)( $region['game_id'] ?? 0 ), $regionId, $runtimeState ); $reports[] = wayfarerRuntimeApplyDedicatedLinearTravelMovement( $runtimeState, $dedicatedTravel, (float)( $gameTravelModifierResult[ 'adjustment' ] ?? 0 ), $gameTravelModifierResult[ 'matched' ] ?? [] ); } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Tick reports and bound-repair reports are immediate diagnostics only. */ return $reports; } ================================================================================ FILE: play/includes/ending-screen.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT relative_path FROM wf_media WHERE id = ? AND game_id = ? AND media_type = 'image' LIMIT 1 " ); $stmt->execute([ $imageMediaId, (int)$gameId, ]); $relativePath = $stmt->fetchColumn(); if ($relativePath !== false) { $relativePath = ltrim( str_replace( '\\', '/', (string)$relativePath ), '/' ); if ( $relativePath !== '' && strpos( $relativePath, '..' ) === false ) { $endingImageUrl = '../' . $relativePath; } } } ?> ================================================================================ FILE: play/includes/entity-state.php ================================================================================ [ * TRAIT_ID => CURRENT_VALUE, * ], * 'discovered' => true|false, // Items only * ]; * * The helper is intentionally additive. Existing state wins. */ function wayfarerEntityDecodeConfig($rawConfig): array { if (is_array($rawConfig)) { return $rawConfig; } if ( $rawConfig === null || $rawConfig === '' ) { return []; } $decoded = json_decode( (string)$rawConfig, true ); return is_array($decoded) ? $decoded : []; } function wayfarerEntityActiveTraitDefinitions( array $config, string $entityType = '' ): array { $definitions = []; /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Rehydrate only mutable universal Entity state. Static presentation rules * such as inventory_visibility remain authored configuration and are not * copied into wf_game_users.state. */ $system = isset($config['system']) && is_array($config['system']) ? $config['system'] : []; $entityType = strtolower( trim($entityType) ); if ($entityType === 'item') { $quantity = isset($system['quantity']) && is_array($system['quantity']) ? $system['quantity'] : []; $definitions['sys_item_quantity'] = [ 'id' => 'sys_item_quantity', 'name' => 'quantity', 'type' => 'number', 'min' => 0, 'max' => $quantity['max'] ?? null, /* WAYFARER_SYSTEM_TRAIT_RANDOM_INITIAL_RUNTIME_V93 */ 'initial_mode' => (string)( $quantity['initial_mode'] ?? 'fixed' ), 'initial' => $quantity['initial'] ?? 0, 'initial_min' => $quantity['initial_min'] ?? null, 'initial_max' => $quantity['initial_max'] ?? null, 'active' => true, 'system' => true, 'system_group' => 'item', 'system_key' => 'quantity', '_runtime_id' => 'sys_item_quantity', ]; } if ($entityType === 'character') { $definitions['sys_character_party_member'] = [ 'id' => 'sys_character_party_member', 'name' => 'party_member', 'type' => 'binary', 'min' => 0, 'max' => 1, 'initial' => (int)($system['party_member'] ?? 0), 'active' => true, 'system' => true, 'system_group' => 'character', 'system_key' => 'party_member', '_runtime_id' => 'sys_character_party_member', ]; } $traits = $config['traits'] ?? []; if (!is_array($traits)) { $traits = []; } foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( array_key_exists( 'active', $trait ) && !$trait['active'] ) { continue; } /* * Legacy safety: if an unmigrated V49-V54 system Trait is encountered, * do not allow it to override the canonical V55 definition. */ if (!empty($trait['system'])) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $trait['_runtime_id'] = $traitId; $definitions[$traitId] = $trait; } return $definitions; } function wayfarerEntitySystemConfigValue( array $config, string $key, $default = null ) { $system = isset($config['system']) && is_array($config['system']) ? $config['system'] : []; return array_key_exists($key, $system) ? $system[$key] : $default; } function wayfarerEntitySystemTraitId( array $definitions, string $systemKey ): ?string { foreach ( $definitions as $traitId => $definition ) { if ( !is_array($definition) || empty($definition['system']) ) { continue; } if ( (string)( $definition['system_key'] ?? '' ) === $systemKey ) { return (string)$traitId; } } return null; } function wayfarerEntityTraitShownOn( array $definition, string $surface ): bool { if (!empty($definition['system'])) { return false; } $surfaces = $definition['display']['surfaces'] ?? []; return is_array($surfaces) && in_array( $surface, $surfaces, true ); } function wayfarerEntityDisplayValue($value): string { if (is_bool($value)) { return $value ? 'Yes' : 'No'; } if ($value === null) { return '—'; } if (is_array($value)) { $json = json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); return $json === false ? '—' : $json; } return (string)$value; } function wayfarerRuntimeLoadActiveEntities( PDO $pdo, int $gameId ): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare( " SELECT id, name, description, entity_type, status, config FROM wf_entities WHERE game_id = ? AND status = 'active' ORDER BY name, id " ); $stmt->execute([ $gameId ]); $rows = $stmt->fetchAll( PDO::FETCH_ASSOC ); return is_array($rows) ? $rows : []; } /* * Additive/recovery-safe Entity initialization. * * Returns true only when runtime state was changed. */ function wayfarerRuntimeEnsureEntityState( PDO $pdo, int $gameId, array &$runtimeState ): bool { if ($gameId <= 0) { return false; } $changed = false; if ( !isset($runtimeState['entities']) || !is_array( $runtimeState['entities'] ) ) { $runtimeState['entities'] = []; $changed = true; } foreach ( wayfarerRuntimeLoadActiveEntities( $pdo, $gameId ) as $entity ) { $entityId = (int)( $entity['id'] ?? 0 ); if ($entityId <= 0) { continue; } $entityKey = (string)$entityId; $config = wayfarerEntityDecodeConfig( $entity['config'] ?? null ); $entityType = strtolower( trim( (string)( $entity['entity_type'] ?? '' ) ) ); $definitions = wayfarerEntityActiveTraitDefinitions( $config, $entityType ); if ( !isset( $runtimeState['entities'][$entityKey] ) || !is_array( $runtimeState['entities'][$entityKey] ) ) { $runtimeState['entities'][$entityKey] = []; $changed = true; } if ( !isset( $runtimeState['entities'][$entityKey]['traits'] ) || !is_array( $runtimeState['entities'][$entityKey]['traits'] ) ) { $runtimeState['entities'][$entityKey]['traits'] = []; $changed = true; } foreach ( $definitions as $traitId => $definition ) { if ( array_key_exists( $traitId, $runtimeState['entities'][$entityKey]['traits'] ) ) { continue; } /* * Reuse Wayfarer's canonical initial-value semantics. */ $runtimeState['entities'][$entityKey]['traits'][$traitId] = wayfarerRuntimeInitialTraitValue( $definition ); $changed = true; } if ( strtolower( trim( (string)( $entity['entity_type'] ?? '' ) ) ) !== 'item' ) { continue; } $quantityTraitId = wayfarerEntitySystemTraitId( $definitions, 'quantity' ); $quantity = $quantityTraitId !== null ? ( $runtimeState['entities'][$entityKey]['traits'] [$quantityTraitId] ?? 0 ) : 0; /* * Discovery is persistent. Possessing the Item at any recovery point * discovers it; returning to zero later never clears discovery. */ if ( !array_key_exists( 'discovered', $runtimeState['entities'][$entityKey] ) ) { $runtimeState['entities'][$entityKey]['discovered'] = is_numeric($quantity) && (float)$quantity > 0; $changed = true; } elseif ( !$runtimeState['entities'][$entityKey]['discovered'] && is_numeric($quantity) && (float)$quantity > 0 ) { $runtimeState['entities'][$entityKey]['discovered'] = true; $changed = true; } } return $changed; } /* * Resolve the player-facing Inventory and Status models. * * Presentation metadata never changes whether a Trait exists in runtime. */ /* * WAYFARER_ENTITY_DETAIL_VIEW_V50 * * Authored media links use the underlying table name as object_type. * Entity images therefore use object_type = wf_entities. */ function wayfarerEntityImageUrl( PDO $pdo, int $gameId, int $entityId ): string { if ( $gameId <= 0 || $entityId <= 0 ) { return ''; } $stmt = $pdo->prepare( " SELECT m.relative_path FROM wf_media_links ml INNER JOIN wf_media m ON m.id = ml.media_id WHERE ml.object_type = 'wf_entities' AND ml.object_id = ? AND ml.slot = 'image' AND m.game_id = ? AND m.media_type = 'image' LIMIT 1 " ); $stmt->execute([ $entityId, $gameId, ]); $relativePath = $stmt->fetchColumn(); if ( $relativePath === false || $relativePath === null || $relativePath === '' ) { return ''; } $relativePath = ltrim( str_replace( '\\', '/', (string)$relativePath ), '/' ); /* * Media uploads live beneath the Wayfarer root. play/ is one level below. * Reject traversal rather than attempting to normalize it. */ if ( $relativePath === '' || strpos( $relativePath, '..' ) !== false ) { return ''; } return '../' . $relativePath; } /* * WAYFARER_STATUS_TRAIT_DISPLAY_V86 * * Builds presentation metadata for a Trait shown on the Status screen. * Existing Traits default to Number/Value. Bar modes require a bounded * numeric Trait; malformed/legacy metadata fails safely back to Number. */ function wayfarerRuntimeStatusTraitPresentation( array $definition, $value ): array { $mode = strtolower( trim( (string)( $definition['display']['status_mode'] ?? 'number' ) ) ); if ( !in_array( $mode, ['number', 'bar', 'both'], true ) ) { $mode = 'number'; } $type = strtolower( trim( (string)( $definition['type'] ?? '' ) ) ); if ($type === 'binary') { $type = 'boolean'; } $minimum = $definition['min'] ?? null; $maximum = $definition['max'] ?? null; $barAvailable = $type === 'number' && is_numeric($value) && is_numeric($minimum) && is_numeric($maximum) && (float)$maximum > (float)$minimum; $barPercent = null; if ($barAvailable) { $barPercent = ( ( (float)$value - (float)$minimum ) / ( (float)$maximum - (float)$minimum ) ) * 100; $barPercent = max( 0, min( 100, $barPercent ) ); } elseif ( $mode === 'bar' || $mode === 'both' ) { $mode = 'number'; } return [ 'value' => wayfarerEntityDisplayValue( $value ), 'display_mode' => $mode, 'bar_available' => $barAvailable, 'bar_percent' => $barPercent, 'bar_min' => $barAvailable ? (float)$minimum : null, 'bar_max' => $barAvailable ? (float)$maximum : null, 'bar_value' => $barAvailable ? (float)$value : null, ]; } function wayfarerRuntimeBuildEntityPresentation( PDO $pdo, int $gameId, array $runtimeState, array $gameConfig = [] ): array { $result = [ 'inventory' => [], 'party' => [], ]; /* * WAYFARER_PLAYER_TRAITS_RUNTIME_V52 * Game and built-in Player presentation now lives in player-state.php. * This resolver owns only Item Inventory and joined Character Entities. */ foreach ( wayfarerRuntimeLoadActiveEntities( $pdo, $gameId ) as $entity ) { $entityId = (int)( $entity['id'] ?? 0 ); if ($entityId <= 0) { continue; } $entityKey = (string)$entityId; $entityState = $runtimeState['entities'][$entityKey] ?? []; $traitState = is_array( $entityState['traits'] ?? null ) ? $entityState['traits'] : []; $config = wayfarerEntityDecodeConfig( $entity['config'] ?? null ); $entityType = strtolower( trim( (string)( $entity['entity_type'] ?? '' ) ) ); $definitions = wayfarerEntityActiveTraitDefinitions( $config, $entityType ); /* * -------------------------------------------------------------- * INVENTORY * -------------------------------------------------------------- */ if ($entityType === 'item') { $quantityTraitId = wayfarerEntitySystemTraitId( $definitions, 'quantity' ); $quantity = $quantityTraitId !== null ? ( $traitState[$quantityTraitId] ?? 0 ) : 0; /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * Inventory visibility is authored presentation configuration, * not mutable playthrough state. */ $visibility = (string) wayfarerEntitySystemConfigValue( $config, 'inventory_visibility', 'while_possessed' ); $discovered = !empty( $entityState['discovered'] ); $show = false; switch ($visibility) { case 'always': $show = true; break; case 'never': $show = false; break; case 'once_discovered': $show = $discovered; break; case 'while_possessed': default: $show = is_numeric($quantity) && (float)$quantity > 0; break; } if (!$show) { continue; } $visibleTraits = []; foreach ( $definitions as $traitId => $definition ) { if ( !wayfarerEntityTraitShownOn( $definition, 'inventory' ) || !array_key_exists( $traitId, $traitState ) ) { continue; } $visibleTraits[] = [ 'id' => $traitId, 'name' => (string)( $definition['name'] ?? $traitId ), 'value' => wayfarerEntityDisplayValue( $traitState[$traitId] ), ]; } $result['inventory'][] = [ 'id' => $entityId, 'name' => (string)( $entity['name'] ?? 'Item' ), 'description' => trim( (string)( $entity['description'] ?? '' ) ), 'image_url' => wayfarerEntityImageUrl( $pdo, $gameId, $entityId ), 'quantity' => wayfarerEntityDisplayValue( $quantity ), 'traits' => $visibleTraits, ]; continue; } /* * -------------------------------------------------------------- * STATUS / PARTY * -------------------------------------------------------------- */ if ($entityType === 'character') { $partyTraitId = wayfarerEntitySystemTraitId( $definitions, 'party_member' ); $inParty = $partyTraitId !== null && !empty( $traitState[$partyTraitId] ); if (!$inParty) { continue; } $visibleTraits = []; foreach ( $definitions as $traitId => $definition ) { if ( !wayfarerEntityTraitShownOn( $definition, 'status' ) || !array_key_exists( $traitId, $traitState ) ) { continue; } $traitPresentation = wayfarerRuntimeStatusTraitPresentation( $definition, $traitState[$traitId] ); $visibleTraits[] = array_merge( [ 'id' => $traitId, 'name' => (string)( $definition['name'] ?? $traitId ), ], $traitPresentation ); } $result['party'][] = [ 'id' => $entityId, 'name' => (string)( $entity['name'] ?? 'Character' ), 'description' => trim( (string)( $entity['description'] ?? '' ) ), 'image_url' => wayfarerEntityImageUrl( $pdo, $gameId, $entityId ), 'traits' => $visibleTraits, ]; } } return $result; } ================================================================================ FILE: play/includes/hud.php ================================================================================ (string)($component['id'] ?? ''), 'type' => $type, 'scope' => (string)$source['scope'], 'source_id' => (string)$source['target_id'], 'label' => $label, 'value' => $value, 'display_value' => $displayValue, 'show_value' => $type === 'number' ? true : !empty($component['show_value']), 'min' => null, 'max' => null, 'percent' => null, ]; if ($type === 'progress_bar') { if (empty($source['bounded'])) { continue; } $min = (float)$source['min']; $max = (float)$source['max']; $percent = (($value - $min) / ($max - $min)) * 100; $percent = max(0, min(100, $percent)); $row['min'] = $min; $row['max'] = $max; $row['percent'] = $percent; } $rendered[] = $row; } return $rendered; }; $renderHudSurface = static function ( string $surface, array $components ): void { if (!$components) { return; } $surfaceLabel = $surface === 'travel' ? 'Travel status' : ( $surface === 'both' ? 'Persistent status' : 'Action status' ); $surfaceClass = $surface === 'travel' ? 'wayfarer-travel-hud' : 'wayfarer-action-hud'; ?> is_array( $gameConfig['player_traits'] ?? null ) ? $gameConfig['player_traits'] : [], ]); } function wayfarerPlayerTraitShownOn( array $definition, string $surface ): bool { $surfaces = $definition['display']['surfaces'] ?? []; return is_array($surfaces) && in_array( $surface, $surfaces, true ); } function wayfarerPlayerDisplayValue($value): string { if (is_bool($value)) { return $value ? 'Yes' : 'No'; } if ($value === null) { return '—'; } if (is_array($value)) { $json = json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); return $json === false ? '—' : $json; } return (string)$value; } /* * Add newly-authored Game Traits to an existing playthrough without changing * any current values. This also fixes the development case where a Game Trait * is added after the playthrough was initialized. */ function wayfarerRuntimeEnsureGameTraitState( array $gameConfig, array &$runtimeState ): bool { $changed = false; if ( !isset($runtimeState['game']) || !is_array($runtimeState['game']) ) { $runtimeState['game'] = []; $changed = true; } if ( !isset($runtimeState['game']['traits']) || !is_array($runtimeState['game']['traits']) ) { $runtimeState['game']['traits'] = []; $changed = true; } foreach ( wayfarerRuntimeActiveTraitDefinitions( $gameConfig ) as $definition ) { $traitId = (string)( $definition['_runtime_id'] ?? '' ); if ($traitId === '') { continue; } if ( array_key_exists( $traitId, $runtimeState['game']['traits'] ) ) { continue; } $runtimeState['game']['traits'][$traitId] = wayfarerRuntimeInitialTraitValue( $definition ); $changed = true; } return $changed; } /* * Additive Player-state initializer / recovery path. * * The saved setup.player_names collection is canonical for which built-in * player characters belong to this playthrough. Changing the authored slot * count later therefore does not invent unnamed people or delete saved people. */ function wayfarerRuntimeEnsurePlayerState( array $gameConfig, int $playerSlots, array &$runtimeState ): bool { $changed = false; if ( !isset($runtimeState['players']) || !is_array($runtimeState['players']) ) { $runtimeState['players'] = []; $changed = true; } $storedNames = $runtimeState['setup']['player_names'] ?? []; if (!is_array($storedNames)) { $storedNames = []; } $names = []; foreach ($storedNames as $storedName) { $storedName = trim((string)$storedName); if ($storedName !== '') { $names[] = $storedName; } } /* * New games validate one name per configured slot before this function is * called. For older playthroughs, saved names win over a later authoring * change to player_slots. */ $definitions = wayfarerPlayerTraitDefinitions( $gameConfig ); foreach ($names as $slot => $name) { $key = (string)$slot; if ( !isset($runtimeState['players'][$key]) || !is_array($runtimeState['players'][$key]) ) { $runtimeState['players'][$key] = []; $changed = true; } if ( (int)( $runtimeState['players'][$key]['slot'] ?? -1 ) !== $slot ) { $runtimeState['players'][$key]['slot'] = $slot; $changed = true; } if ( (string)( $runtimeState['players'][$key]['name'] ?? '' ) !== $name ) { $runtimeState['players'][$key]['name'] = $name; $changed = true; } if ( !isset($runtimeState['players'][$key]['traits']) || !is_array($runtimeState['players'][$key]['traits']) ) { $runtimeState['players'][$key]['traits'] = []; $changed = true; } foreach ($definitions as $definition) { $traitId = (string)( $definition['_runtime_id'] ?? '' ); if ($traitId === '') { continue; } if ( array_key_exists( $traitId, $runtimeState['players'][$key]['traits'] ) ) { continue; } $runtimeState['players'][$key]['traits'][$traitId] = wayfarerRuntimeInitialTraitValue( $definition ); $changed = true; } } return $changed; } /* * Convenience recovery used by the play page and tick API. */ function wayfarerRuntimeEnsurePlayerAndGameState( PDO $pdo, int $gameId, array &$runtimeState ): bool { if ($gameId <= 0) { return false; } $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $game = $stmt->fetch(PDO::FETCH_ASSOC); if (!$game) { return false; } $gameConfig = wayfarerRuntimeDecodeConfig( $game['config'] ?? null ); $changed = wayfarerRuntimeEnsureGameTraitState( $gameConfig, $runtimeState ); if ( wayfarerRuntimeEnsurePlayerState( $gameConfig, (int)( $game['player_slots'] ?? 0 ), $runtimeState ) ) { $changed = true; } return $changed; } function wayfarerRuntimeBuildPlayerPresentation( array $gameConfig, array $runtimeState ): array { $result = [ 'players' => [], 'game_status_traits' => [], ]; /* Shared Game Traits. */ foreach ( wayfarerRuntimeActiveTraitDefinitions( $gameConfig ) as $definition ) { $traitId = (string)( $definition['_runtime_id'] ?? '' ); if ($traitId === '') { continue; } if ( !wayfarerPlayerTraitShownOn( $definition, 'status' ) || !array_key_exists( $traitId, $runtimeState['game']['traits'] ?? [] ) ) { continue; } /* WAYFARER_STATUS_TRAIT_DISPLAY_V86 */ $traitPresentation = wayfarerRuntimeStatusTraitPresentation( $definition, $runtimeState['game']['traits'][$traitId] ); $result['game_status_traits'][] = array_merge( [ 'id' => $traitId, 'name' => (string)( $definition['name'] ?? $traitId ), ], $traitPresentation ); } $playerDefinitions = wayfarerPlayerTraitDefinitions( $gameConfig ); $players = is_array($runtimeState['players'] ?? null) ? $runtimeState['players'] : []; uasort( $players, function ($a, $b) { return (int)($a['slot'] ?? 0) <=> (int)($b['slot'] ?? 0); } ); foreach ($players as $key => $player) { if (!is_array($player)) { continue; } $name = trim( (string)( $player['name'] ?? '' ) ); if ($name === '') { continue; } $traitState = is_array($player['traits'] ?? null) ? $player['traits'] : []; $visibleTraits = []; foreach ($playerDefinitions as $definition) { $traitId = (string)( $definition['_runtime_id'] ?? '' ); if ($traitId === '') { continue; } if ( !wayfarerPlayerTraitShownOn( $definition, 'status' ) || !array_key_exists( $traitId, $traitState ) ) { continue; } $traitPresentation = wayfarerRuntimeStatusTraitPresentation( $definition, $traitState[$traitId] ); $visibleTraits[] = array_merge( [ 'id' => $traitId, 'name' => (string)( $definition['name'] ?? $traitId ), ], $traitPresentation ); } $result['players'][] = [ 'slot' => (int)($player['slot'] ?? $key), 'name' => $name, 'traits' => $visibleTraits, ]; } return $result; } ================================================================================ FILE: play/includes/presentation-screen.php ================================================================================ prepare( " SELECT l.*, r.game_id, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE l.id = ? AND r.game_id = ? LIMIT 1 " ); $stmt->execute([ $presentationId, (int)$gameId, ]); $presentationRecord = $stmt->fetch( PDO::FETCH_ASSOC ); if ($presentationRecord) { /* * Media links use the authored table name as object_type. */ $mediaStmt = $pdo->prepare( " SELECT m.id, m.name, m.relative_path, m.mime_type FROM wf_media_links ml INNER JOIN wf_media m ON m.id = ml.media_id WHERE ml.object_type = 'wf_locations' AND ml.object_id = ? AND ml.slot = 'image' AND m.game_id = ? AND m.media_type = 'image' LIMIT 1 " ); $mediaStmt->execute([ $presentationId, (int)$gameId, ]); $presentationImage = $mediaStmt->fetch( PDO::FETCH_ASSOC ); } } /* * --------------------------------------------------------------- * STORYLET * WAYFARER_STORYLET_PRESENTATION_V59 * --------------------------------------------------------------- */ if ( $presentationType === 'storylet' ) { $presentationRecord = wayfarerRuntimeLoadStorylet( $pdo, (int)$gameId, $presentationId ); if ($presentationRecord) { $mediaStmt = $pdo->prepare( " SELECT m.id, m.name, m.relative_path, m.mime_type FROM wf_media_links ml INNER JOIN wf_media m ON m.id = ml.media_id WHERE ml.object_type = 'wf_storylets' AND ml.object_id = ? AND ml.slot = 'image' AND m.game_id = ? AND m.media_type = 'image' LIMIT 1 " ); $mediaStmt->execute([ $presentationId, (int)$gameId, ]); $presentationImage = $mediaStmt->fetch( PDO::FETCH_ASSOC ); /* * Conditions are evaluated AFTER the current Storylet's Effects have * executed, because entry persisted the mutated state first. */ $presentationBranches = wayfarerRuntimeEligibleStoryletBranches( $pdo, (int)$gameId, $presentationId, $state ); } } if (!$presentationRecord) { /* * Keep the player from becoming trapped if an authored object is removed * after its presentation state was persisted. */ $presentationRecord = [ 'name' => 'Continue', 'description' => 'This presentation is no longer available.', ]; } $presentationName = trim( (string)( $presentationRecord['name'] ?? '' ) ); if ($presentationName === '') { $presentationName = $presentationType === 'storylet' ? 'Storylet' : 'Location'; } $presentationDescription = trim( (string)( $presentationRecord['description'] ?? '' ) ); /* WAYFARER_STORYLET_BINDING_RUNTIME_V79 */ if ( $presentationType === 'storylet' && isset($presentation['bindings']) && is_array($presentation['bindings']) ) { $presentationName = wayfarerRuntimeInterpolateStoryletBindings( $presentationName, $presentation['bindings'] ); $presentationDescription = wayfarerRuntimeInterpolateStoryletBindings( $presentationDescription, $presentation['bindings'] ); } /* WAYFARER_TRIGGERING_LOCATION_CONTEXT_V103 */ if ($presentationType === 'storylet') { $sourceContext = isset($presentation['source_context']) && is_array($presentation['source_context']) ? $presentation['source_context'] : ($state['runtime']['active_storylet_source_context'] ?? []); if (is_array($sourceContext)) { $presentationName = wayfarerRuntimeInterpolateStoryletSourceContext($presentationName, $sourceContext); $presentationDescription = wayfarerRuntimeInterpolateStoryletSourceContext($presentationDescription, $sourceContext); } } $presentationImageUrl = ''; if ( is_array($presentationImage) && !empty( $presentationImage['relative_path'] ) ) { $relativePath = ltrim( str_replace( '\\', '/', (string)$presentationImage[ 'relative_path' ] ), '/' ); /* * Media uploads are stored beneath the Wayfarer root. play/ is one level * below that root. */ if ( $relativePath !== '' && strpos( $relativePath, '..' ) === false ) { $presentationImageUrl = '../' . $relativePath; } } ?> ================================================================================ FILE: play/includes/runtime-bootstrap.php ================================================================================ ================================================================================ FILE: play/includes/runtime.php ================================================================================ 'location', 'location_id' => (int)($location['id'] ?? 0), 'region_id' => (int)($runtimeState['navigation']['region_id'] ?? 0), 'position' => $runtimeState['navigation']['position'] ?? null, 'tick' => (int)($runtimeState['runtime']['tick'] ?? 0), ]; /* WAYFARER_TRIGGERING_LOCATION_CONTEXT_V103 */ $locationConfig = isset($location['_runtime_config']) && is_array($location['_runtime_config']) ? $location['_runtime_config'] : wayfarerRuntimeDecodeConfig($location['config'] ?? null); $sourceContext['location_name'] = trim((string)($location['name'] ?? '')); $sourceContext['traits'] = []; foreach (($locationConfig['traits'] ?? []) as $trait) { if (!is_array($trait) || (array_key_exists('active',$trait) && !$trait['active'])) continue; $traitName=trim((string)($trait['name'] ?? $trait['label'] ?? '')); if($traitName==='') continue; $slug=strtolower(trim((string)preg_replace('/[^a-z0-9]+/i','_',$traitName),'_')); if($slug==='') continue; $sourceContext['traits'][$slug] = [ 'name'=>$traitName, 'trait_id'=>(string)($trait['id'] ?? $trait['system_key'] ?? ''), 'value'=>array_key_exists('initial',$trait) ? $trait['initial'] : ($trait['value'] ?? null), ]; } $runtimeState['runtime']['active_storylet_source_context'] = $sourceContext; unset( $runtimeState['runtime']['pending_storylet_source_context'] ); wayfarerRuntimeEnterTriggeredStoryletInState( $pdo, $gameId, $storyletId, $runtimeState, [], 'location' ); if ( isset($runtimeState['runtime']['presentation']) && is_array($runtimeState['runtime']['presentation']) && (string)($runtimeState['runtime']['presentation']['type'] ?? '') === 'storylet' ) { $runtimeState['runtime']['presentation']['source_context'] = $sourceContext; $runtimeState['runtime']['presentation']['location_trigger'] = $trigger; } if ( isset($runtimeState['runtime']['last_storylet_entry']) && is_array($runtimeState['runtime']['last_storylet_entry']) ) { $runtimeState['runtime']['last_storylet_entry']['source_context'] = $sourceContext; $runtimeState['runtime']['last_storylet_entry']['location_trigger'] = $trigger; } $runtimeState['runtime']['last_location_storylet_transition'] = [ 'location_id' => (int)($sourceContext['location_id'] ?? 0), 'storylet_id' => (int)($runtimeState['runtime']['presentation']['id'] ?? $storyletId), 'requested_storylet_id' => $storyletId, 'trigger' => $trigger, 'tick' => (int)($runtimeState['runtime']['tick'] ?? 0), 'source_context' => $sourceContext, ]; return true; } /* WAYFARER_RUNTIME_STARTING_SETUP_V36 */ /* WAYFARER_ENTITY_RUNTIME_STATE_V49 */ /* WAYFARER_PLAYER_TRAITS_RUNTIME_V52 */ /** * Minimal Wayfarer play-runtime helpers. * * v14 deliberately implements only a persisted tick counter. */ /** * Decode wf_game_users.state safely. */ function wayfarerRuntimeDecodeState($rawState): array { if (is_array($rawState)) { return $rawState; } if ($rawState === null || $rawState === '') { return []; } $decoded = json_decode( (string)$rawState, true ); return is_array($decoded) ? $decoded : []; } /** * Encode runtime state for the JSON column. */ function wayfarerRuntimeEncodeState(array $state): string { $json = json_encode( $state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ($json === false) { throw new RuntimeException( 'Unable to encode Wayfarer runtime state.' ); } return $json; } /* |-------------------------------------------------------------------------- | Canonical Runtime Initialization |-------------------------------------------------------------------------- | | WAYFARER_RUNTIME_INITIALIZATION_V31 | | New-game state is created here rather than piecemeal by the play page. | Future startup systems (inventory, entities, calendar, etc.) can extend this | single initializer. | */ function wayfarerRuntimeDecodeConfig( $rawConfig ): array { if (is_array($rawConfig)) { return $rawConfig; } if ( $rawConfig === null || $rawConfig === '' ) { return []; } $decoded = json_decode( (string)$rawConfig, true ); return is_array($decoded) ? $decoded : []; } /* |-------------------------------------------------------------------------- | Game clock + Linear travel HUD | WAYFARER_TRAVEL_TIME_HUD_V61 |-------------------------------------------------------------------------- */ function wayfarerRuntimeTimeConfig(array $gameConfig): array { $raw = isset($gameConfig['time']) && is_array($gameConfig['time']) ? $gameConfig['time'] : []; $mode = strtolower(trim((string)($raw['mode'] ?? 'none'))); if (!in_array($mode, ['none', 'days', 'hours'], true)) { $mode = 'none'; } $config = [ 'mode' => $mode, 'starting_day' => max(1, (int)($raw['starting_day'] ?? 1)), 'ticks_per_day' => max(1, (int)($raw['ticks_per_day'] ?? 1)), 'starting_hour' => max(0, (int)($raw['starting_hour'] ?? 8)), 'ticks_per_hour' => max(1, (int)($raw['ticks_per_hour'] ?? 1)), 'hours_per_day' => max(1, (int)($raw['hours_per_day'] ?? 24)), ]; if ($config['starting_hour'] >= $config['hours_per_day']) { $config['starting_hour'] = $config['hours_per_day'] - 1; } return $config; } function wayfarerRuntimeInitializeClockFromConfig( array $gameConfig, array &$runtimeState ): void { if (!isset($runtimeState['runtime']) || !is_array($runtimeState['runtime'])) { $runtimeState['runtime'] = []; } $config = wayfarerRuntimeTimeConfig($gameConfig); if ($config['mode'] === 'none') { unset($runtimeState['runtime']['clock']); return; } $clock = [ 'mode' => $config['mode'], 'day' => $config['starting_day'], ]; if ($config['mode'] === 'days') { $clock['tick_in_day'] = 0; } else { $clock['hour'] = $config['starting_hour']; $clock['tick_in_hour'] = 0; } $runtimeState['runtime']['clock'] = $clock; } function wayfarerRuntimeLoadGameConfig(PDO $pdo, int $gameId): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare('SELECT config FROM wf_games WHERE id = ? LIMIT 1'); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); return wayfarerRuntimeDecodeConfig($raw === false ? null : $raw); } function wayfarerRuntimeEnsureClock( PDO $pdo, int $gameId, array &$runtimeState ): array { $gameConfig = wayfarerRuntimeLoadGameConfig($pdo, $gameId); $timeConfig = wayfarerRuntimeTimeConfig($gameConfig); $clock = $runtimeState['runtime']['clock'] ?? null; if ($timeConfig['mode'] === 'none') { unset($runtimeState['runtime']['clock']); return $timeConfig; } if (!is_array($clock) || (string)($clock['mode'] ?? '') !== $timeConfig['mode']) { wayfarerRuntimeInitializeClockFromConfig($gameConfig, $runtimeState); } return $timeConfig; } function wayfarerRuntimeAdvanceClock( PDO $pdo, int $gameId, array &$runtimeState ): void { $config = wayfarerRuntimeEnsureClock($pdo, $gameId, $runtimeState); if ($config['mode'] === 'none') { return; } $clock =& $runtimeState['runtime']['clock']; $clock['day'] = max(1, (int)($clock['day'] ?? $config['starting_day'])); if ($config['mode'] === 'days') { $clock['tick_in_day'] = max(0, (int)($clock['tick_in_day'] ?? 0)) + 1; if ($clock['tick_in_day'] >= $config['ticks_per_day']) { $clock['day']++; $clock['tick_in_day'] = 0; } return; } $clock['hour'] = max(0, (int)($clock['hour'] ?? $config['starting_hour'])); $clock['tick_in_hour'] = max(0, (int)($clock['tick_in_hour'] ?? 0)) + 1; if ($clock['tick_in_hour'] >= $config['ticks_per_hour']) { $clock['tick_in_hour'] = 0; $clock['hour']++; if ($clock['hour'] >= $config['hours_per_day']) { $clock['hour'] = 0; $clock['day']++; } } } function wayfarerRuntimeClockLabel(array $runtimeState, array $timeConfig): string { if ($timeConfig['mode'] === 'none') { return ''; } $clock = $runtimeState['runtime']['clock'] ?? []; $day = max(1, (int)($clock['day'] ?? $timeConfig['starting_day'])); if ($timeConfig['mode'] === 'days') { return 'Day ' . $day; } $hour = max(0, (int)($clock['hour'] ?? $timeConfig['starting_hour'])); if ($timeConfig['hours_per_day'] === 24) { $displayHour = $hour % 12; if ($displayHour === 0) { $displayHour = 12; } $suffix = $hour < 12 ? 'AM' : 'PM'; return 'Day ' . $day . ' · ' . $displayHour . ':00 ' . $suffix; } return 'Day ' . $day . ' · Hour ' . $hour; } /* * WAYFARER_BIDIRECTIONAL_TRAVEL_V71 * * Runtime direction is journey state, not Region authoring state. +1 follows * the Region's authored forward Tick Effect; -1 reverses it. Keeping the * authored Add/Subtract sign as the base preserves existing Regions while * allowing the same line to be traversed in either direction. */ function wayfarerRuntimeNavigationDirection(array $runtimeState): int { $direction = (int)( $runtimeState['navigation']['direction'] ?? 1 ); return $direction < 0 ? -1 : 1; } /* * WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 * * Returns canonical dedicated travel configuration, or null for Regions that * still use the legacy Position Tick Effect movement path. */ function wayfarerRuntimeDedicatedLinearTravel( array $regionConfig ): ?array { $travel = isset($regionConfig['travel']) && is_array($regionConfig['travel']) ? $regionConfig['travel'] : null; if ( $travel === null || empty($travel['enabled']) || !isset($travel['base_distance']) || !is_numeric($travel['base_distance']) || (float)$travel['base_distance'] <= 0 ) { return null; } return [ 'base_distance' => (float)$travel['base_distance'], 'allow_turnaround' => !empty( $travel['allow_turnaround'] ), ]; } /* WAYFARER_GAME_TRAVEL_MODIFIERS_MOVEMENT_V73 */ function wayfarerRuntimeApplyDedicatedLinearTravelMovement( array &$runtimeState, array $travel, float $modifierAdjustment = 0.0, array $matchedModifiers = [] ): array { if ( !isset($runtimeState['navigation']) || !is_array($runtimeState['navigation']) ) { $runtimeState['navigation'] = []; } $before = $runtimeState['navigation']['position'] ?? 0; if (!is_numeric($before)) { return [ 'scope' => 'navigation', 'type' => 'position', 'target_id' => 'position', 'applied' => false, 'error' => 'Navigation position must be numeric.', ]; } $baseDistance = max( 0.0, (float)( $travel['base_distance'] ?? 0 ) ); /* * Modifiers may slow travel to zero, but never reverse the player's * chosen direction. Direction is applied only after effective distance is * clamped to a non-negative value. */ $distance = max( 0.0, $baseDistance + $modifierAdjustment ); $direction = wayfarerRuntimeNavigationDirection( $runtimeState ); $after = max( 0.0, (float)$before + ($distance * $direction) ); $after = wayfarerNormalizeRuntimeNumber( $after ); $runtimeState['navigation']['position'] = $after; return [ 'scope' => 'navigation', 'type' => 'position', 'target_id' => 'position', 'operation' => 'travel', 'base_distance' => wayfarerNormalizeRuntimeNumber( $baseDistance ), 'modifier_adjustment' => wayfarerNormalizeRuntimeNumber( $modifierAdjustment ), 'matched_modifiers' => array_values( $matchedModifiers ), 'amount' => wayfarerNormalizeRuntimeNumber( $distance ), 'direction' => $direction, 'before' => wayfarerNormalizeRuntimeNumber( (float)$before ), 'after' => $after, 'applied' => true, 'error' => '', ]; } function wayfarerRuntimeLinearTravelDirection( array $regionConfig, array $runtimeState ): int { $runtimeDirection = wayfarerRuntimeNavigationDirection( $runtimeState ); /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ if ( wayfarerRuntimeDedicatedLinearTravel( $regionConfig ) !== null ) { return $runtimeDirection; } $effects = isset($regionConfig['tick_effects']) && is_array($regionConfig['tick_effects']) ? $regionConfig['tick_effects'] : []; foreach ($effects as $effect) { if (!is_array($effect)) { continue; } if ( (string)($effect['target_scope'] ?? '') !== 'navigation' || (string)($effect['target_type'] ?? '') !== 'position' || (string)($effect['target_id'] ?? '') !== 'position' ) { continue; } $branches = wayfarerRuntimeTickEffectBranches($effect); foreach ($branches as $branch) { $operation = strtolower(trim((string)($branch['operation'] ?? ''))); if ($operation === 'subtract') { return -1 * $runtimeDirection; } if ($operation === 'add') { return 1 * $runtimeDirection; } } } return $runtimeDirection; } function wayfarerRuntimeBuildTravelStatus( PDO $pdo, int $gameId, array &$runtimeState ): array { $gameConfig = wayfarerRuntimeLoadGameConfig($pdo, $gameId); $timeConfig = wayfarerRuntimeEnsureClock($pdo, $gameId, $runtimeState); $result = [ 'time_label' => wayfarerRuntimeClockLabel($runtimeState, $timeConfig), 'region_name' => '', 'distance_label' => 'units', 'next_location_id' => 0, 'next_location_name' => '', 'distance_to_next' => null, 'next_label' => '', /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ 'dedicated_travel' => false, 'base_distance_per_tick' => null, 'allow_turnaround' => false, 'can_turn_around' => false, 'direction' => 1, ]; $regionId = (int)($runtimeState['navigation']['region_id'] ?? 0); $position = $runtimeState['navigation']['position'] ?? null; if ($regionId <= 0 || !is_numeric($position)) { return $result; } $stmt = $pdo->prepare( 'SELECT id, name, navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1' ); $stmt->execute([$regionId, $gameId]); $region = $stmt->fetch(PDO::FETCH_ASSOC); if (!$region) { return $result; } $result['region_name'] = trim((string)($region['name'] ?? '')); if (strtolower(trim((string)($region['navigation_type'] ?? ''))) !== 'linear') { return $result; } $regionConfig = wayfarerRuntimeDecodeConfig($region['config'] ?? null); $distanceLabel = trim((string)($regionConfig['distance_label'] ?? 'units')); $result['distance_label'] = $distanceLabel !== '' ? $distanceLabel : 'units'; $direction = wayfarerRuntimeLinearTravelDirection($regionConfig, $runtimeState); $current = (float)$position; /* WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 */ $dedicatedTravel = wayfarerRuntimeDedicatedLinearTravel( $regionConfig ); $result['direction'] = $direction; if ($dedicatedTravel !== null) { $result['dedicated_travel'] = true; $result['base_distance_per_tick'] = wayfarerNormalizeRuntimeNumber( (float)$dedicatedTravel[ 'base_distance' ] ); $result['allow_turnaround'] = !empty( $dedicatedTravel[ 'allow_turnaround' ] ); /* * Do not offer a reversal that would immediately point out of the * legal lower boundary. If already traveling toward Start at zero, * turning around toward End remains valid. */ $result['can_turn_around'] = $result['allow_turnaround'] && !( $current <= 0 && $direction > 0 ); } $locationStmt = $pdo->prepare( "SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id" ); $locationStmt->execute([$regionId]); $best = null; $bestDistance = null; foreach ($locationStmt->fetchAll(PDO::FETCH_ASSOC) as $location) { $locationConfig = wayfarerRuntimeDecodeConfig($location['config'] ?? null); if (!isset($locationConfig['position']) || !is_numeric($locationConfig['position'])) { continue; } $locationPosition = (float)$locationConfig['position']; $ahead = $direction < 0 ? $locationPosition < $current : $locationPosition > $current; if (!$ahead) { continue; } $location['_runtime_config'] = $locationConfig; if (!wayfarerRuntimeLocationConditionsPass($pdo, $gameId, $location, $runtimeState)) { continue; } $distance = abs($locationPosition - $current); if ($best === null || $distance < $bestDistance) { $best = $location; $bestDistance = $distance; } } if ($best !== null) { $distance = wayfarerNormalizeRuntimeNumber((float)$bestDistance); $name = trim((string)($best['name'] ?? 'Location')) ?: 'Location'; $result['next_location_id'] = (int)($best['id'] ?? 0); $result['next_location_name'] = $name; $result['distance_to_next'] = $distance; $result['next_label'] = $name . ' — ' . $distance . ' ' . $result['distance_label']; } return $result; } function wayfarerRuntimeActiveTraitDefinitions( array $config ): array { $traits = $config['traits'] ?? []; if (!is_array($traits)) { return []; } $result = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( array_key_exists( 'active', $trait ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $trait['_runtime_id'] = $traitId; $result[] = $trait; } return $result; } /* * WAYFARER_TRAIT_RANDOM_INITIAL_RUNTIME_V93 * * Number Traits may initialize from either one fixed value or one random * Min–Max range. This function is called only when the owning runtime Trait * value does not already exist, so a random initial value is rolled once and * then persisted like every other Trait value. */ function wayfarerRuntimeInitialTraitValue( array $trait ) { $type = strtolower( trim( (string)( $trait['type'] ?? '' ) ) ); $initialMode = strtolower( trim( (string)( $trait['initial_mode'] ?? 'fixed' ) ) ); if ( $type === 'number' && $initialMode === 'random' ) { $minimum = $trait['initial_min'] ?? null; $maximum = $trait['initial_max'] ?? null; if ( is_numeric($minimum) && is_numeric($maximum) && (float)$minimum <= (float)$maximum ) { $rolled = wayfarerRuntimeRollEffectValue([ 'value_mode' => 'random', 'value_min' => $minimum, 'value_max' => $maximum, ]); if (!empty($rolled['valid'])) { $value = $rolled['value']; /* * Authored Trait bounds remain invariants even if malformed * legacy JSON contains a random range outside those bounds. */ if ( function_exists( 'wayfarerRuntimeClampValueToTraitDefinition' ) ) { $bounded = wayfarerRuntimeClampValueToTraitDefinition( $value, $trait ); if (!empty($bounded['valid'])) { return $bounded['value']; } } return $value; } } } if ( array_key_exists( 'initial', $trait ) ) { return $trait['initial']; } if ( $type === 'number' || $type === 'binary' || $type === 'boolean' ) { return 0; } return null; } function wayfarerRuntimeInitialTraitState( array $config ): array { $state = []; foreach ( wayfarerRuntimeActiveTraitDefinitions( $config ) as $trait ) { $traitId = (string)$trait['_runtime_id']; $state[$traitId] = wayfarerRuntimeInitialTraitValue( $trait ); } return $state; } /* |-------------------------------------------------------------------------- | Persisted state for every Region |-------------------------------------------------------------------------- | WAYFARER_ALL_REGION_STATE_V46 | | Game-wide Conditions may inspect a Region that the player has never visited. | Therefore every authored Region needs persistent Trait state, not only the | current Region. | | This helper is additive/recovery-safe: | - existing runtime values are never overwritten; | - missing Regions or newly-added Traits receive their authored initial; | - stale historical values are not destructively removed here. | */ function wayfarerRuntimeEnsureAllRegionTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['regions']) || !is_array($runtimeState['regions']) ) { $runtimeState['regions'] = []; } $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? ORDER BY id " ); $stmt->execute([ $gameId ]); $regions = $stmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($regions as $region) { $regionId = (int)( $region['id'] ?? 0 ); if ($regionId <= 0) { continue; } $regionKey = (string)$regionId; $config = wayfarerRuntimeDecodeConfig( $region['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset( $runtimeState['regions'][$regionKey] ) || !is_array( $runtimeState['regions'][$regionKey] ) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset( $runtimeState['regions'][$regionKey]['traits'] ) || !is_array( $runtimeState['regions'][$regionKey]['traits'] ) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['regions'][$regionKey]['traits'] ) ) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $initialValue; } } } /* |-------------------------------------------------------------------------- | Persisted state for every Location | WAYFARER_ALL_LOCATION_STATE_V56 |-------------------------------------------------------------------------- | | Locations can own Traits, so universal Conditions/Effects need persistent | per-playthrough Location Trait state just like Regions. | */ function wayfarerRuntimeEnsureAllLocationTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['locations']) || !is_array($runtimeState['locations']) ) { $runtimeState['locations'] = []; } $stmt = $pdo->prepare( " SELECT l.id, l.region_id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $location ) { $locationId = (int)($location['id'] ?? 0); if ($locationId <= 0) { continue; } $locationKey = (string)$locationId; $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset($runtimeState['locations'][$locationKey]) || !is_array($runtimeState['locations'][$locationKey]) ) { $runtimeState['locations'][$locationKey] = []; } /* * WAYFARER_LIVE_LOCATION_ANCHORS_V76 * * These are definition-derived spatial facts, so refresh them every * ensure pass. Moving a Location in Builder therefore changes the * anchor without rewriting authored Conditions. */ $runtimeState['locations'][$locationKey]['region_id'] = (int)( $location['region_id'] ?? 0 ); $runtimeState['locations'][$locationKey]['position'] = isset($config['position']) && is_numeric($config['position']) ? (float)$config['position'] : null; if ( !isset($runtimeState['locations'][$locationKey]['traits']) || !is_array($runtimeState['locations'][$locationKey]['traits']) ) { $runtimeState['locations'][$locationKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['locations'][$locationKey]['traits'] ) ) { continue; } $runtimeState['locations'][$locationKey]['traits'][$traitId] = $initialValue; } } } function wayfarerRuntimeLoadStartingRegion( PDO $pdo, int $gameId, ?array $gameConfig = null ): ?array { if ($gameId <= 0) { return null; } if ($gameConfig === null) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { return null; } $gameConfig = wayfarerRuntimeDecodeConfig( $rawConfig ); } $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT * FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $startingRegionId, $gameId, ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); return $region ?: null; } function wayfarerRuntimeInitializeGame( PDO $pdo, int $gameUserId, array $setupInput = [] ): array { if ($gameUserId <= 0) { throw new RuntimeException( 'A valid game-user ID is required to initialize the game.' ); } try { $pdo->beginTransaction(); /* * Lock the playthrough row so two initialize requests cannot create * competing starting states. */ $stmt = $pdo->prepare( " SELECT gu.id, gu.game_id, gu.user_id, gu.role, gu.state, g.config AS game_config, g.player_slots AS player_slots FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id WHERE gu.id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } /* * Initialization is intentionally one-way. To restart a development * game, clear wf_game_users.state to NULL first. */ if ( $row['state'] !== null && $row['state'] !== '' ) { throw new RuntimeException( 'This game-user already has runtime state.' ); } $gameId = (int)$row['game_id']; $gameConfig = wayfarerRuntimeDecodeConfig( $row['game_config'] ?? null ); $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { throw new RuntimeException( 'This Game does not have a Starting Region configured.' ); } $startingRegion = wayfarerRuntimeLoadStartingRegion( $pdo, $gameId, $gameConfig ); if (!$startingRegion) { throw new RuntimeException( 'The configured Starting Region does not exist for this Game.' ); } $regionConfig = wayfarerRuntimeDecodeConfig( $startingRegion['config'] ?? null ); $gameTraits = wayfarerRuntimeInitialTraitState( $gameConfig ); $regionTraits = wayfarerRuntimeInitialTraitState( $regionConfig ); $navigation = [ 'region_id' => (int)$startingRegion['id'], /* WAYFARER_BIDIRECTIONAL_TRAVEL_V71 */ 'direction' => 1, ]; /* * Linear travel currently begins at coordinate zero. * Other navigation modes will get their own authored start-state * rules when those modes are implemented. */ if ( strtolower( trim( (string)( $startingRegion['navigation_type'] ?? '' ) ) ) === 'linear' ) { $navigation['position'] = 0; } $regionKey = (string)$startingRegion['id']; $state = [ /* * WAYFARER_RUNTIME_SCHEMA_V55 * WAYFARER_BIDIRECTIONAL_TRAVEL_V71 * Version the persisted playthrough contract explicitly. */ 'schema_version' => 2, 'game' => [ 'traits' => $gameTraits, ], 'navigation' => $navigation, 'regions' => [ $regionKey => [ 'traits' => $regionTraits, ], ], 'runtime' => [ 'tick' => 0, ], ]; /* WAYFARER_INITIALIZE_GAME_CLOCK_V61 */ wayfarerRuntimeInitializeClockFromConfig( $gameConfig, $state ); /* * WAYFARER_ALL_REGION_STATE_V46 * * Global conditions can inspect Region state before that Region has * ever been visited. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $state ); /* WAYFARER_INITIALIZE_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_V49 * * Items and Characters receive canonical per-playthrough state before * any starting setup effects are applied. */ wayfarerRuntimeEnsureEntityState( $pdo, $gameId, $state ); /* * WAYFARER_APPLY_STARTING_CHOICES_V36 * * Validate all player-entered setup data before persisting anything. * Then apply the selected options' starting effects to the canonical * initial runtime state. */ $preparedSetup = wayfarerRuntimePrepareStartingSetup( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $setupInput ); /* * WAYFARER_PLAYER_TRAITS_RUNTIME_V52 * * Persist the validated player-character names before creating the * per-player Trait state. Each slot receives independent values. */ $state['setup'] = [ 'player_names' => $preparedSetup['player_names'], 'choices' => $preparedSetup['choices'], ]; wayfarerRuntimeEnsureGameTraitState( $gameConfig, $state ); wayfarerRuntimeEnsurePlayerState( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $state ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $startingEffectReports = []; foreach ( $preparedSetup['effects'] as $startingEffect ) { /* WAYFARER_STARTING_CHOICE_RANDOM_RUNTIME_V58 */ $resolvedStartingValue = wayfarerRuntimeRollEffectValue( $startingEffect ); if (!$resolvedStartingValue['valid']) { throw new RuntimeException( 'Unable to resolve a Starting Choice effect value: ' . $resolvedStartingValue['error'] ); } $concreteStartingEffect = $startingEffect; $concreteStartingEffect['value'] = $resolvedStartingValue['value']; $report = wayfarerApplyRuntimeEffect( $state, $concreteStartingEffect, (int)$startingRegion['id'], $traitDefinitions ); if ( !empty($report['error']) ) { throw new RuntimeException( 'Unable to apply a Starting Choice effect: ' . $report['error'] ); } $report['value_mode'] = $resolvedStartingValue['mode']; $report['rolled_value'] = $resolvedStartingValue['value']; if ($resolvedStartingValue['mode'] === 'random') { $report['random_min'] = $resolvedStartingValue['min']; $report['random_max'] = $resolvedStartingValue['max']; } $startingEffectReports[] = $report; } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * * Starting-effect reports are intentionally not persisted. The effects * themselves have already mutated canonical state. */ /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 * * A game that begins exactly on an active Location should present that * Location before ordinary Action Mode. */ $startingLocation = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $state ); if ($startingLocation) { /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Starting on a Location is a real Location trigger. Apply its * one-time Effects before opening Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, $gameId, $startingLocation, $state ); $locationStoryletOpened = wayfarerRuntimePromotePendingLocationStorylet( $pdo, $gameId, $startingLocation, $state, 'initial' ); if (!$locationStoryletOpened) { $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$startingLocation['id'], 'phase' => 'presentation', 'trigger' => 'initial', 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } } /* WAYFARER_GAME_ENDINGS_INITIAL_V45 */ wayfarerRuntimeEvaluateGameEndings( $pdo, $gameId, $state ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => $gameId, 'starting_region_id' => (int)$startingRegion['id'], 'state' => $state, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Resolve a game-user record for the tick prototype. * * Preferred: * explicit wf_game_users.id * * Temporary development fallback: * first wf_game_users record. * * The fallback exists only because Wayfarer has not yet wired the play * page to authentication / game selection. Once that exists, remove the * fallback and always pass the active game_user_id explicitly. */ function wayfarerRuntimeResolveGameUserId( PDO $pdo, ?int $requestedId = null ): int { if ($requestedId !== null && $requestedId > 0) { $stmt = $pdo->prepare( " SELECT id FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $requestedId ]); $id = $stmt->fetchColumn(); if ($id !== false) { return (int)$id; } throw new RuntimeException( 'The requested game-user record does not exist.' ); } $id = $pdo ->query( " SELECT id FROM wf_game_users ORDER BY id LIMIT 1 " ) ->fetchColumn(); if ($id === false) { throw new RuntimeException( 'No wf_game_users record exists.' ); } return (int)$id; } /** * Load one game-user runtime state. */ function wayfarerRuntimeLoad( PDO $pdo, int $gameUserId ): array { $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'state' => $state, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /** * Advance exactly one tick. * * This is intentionally the entire v14 game engine: * * state.runtime.tick += 1 * * The transaction and row lock are already in place so later work can add * movement/effects without changing the request architecture. */ /* |-------------------------------------------------------------------------- | Linear Location crossing | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | A tick may move farther than the distance to the next authored Location. | This resolver finds the FIRST active Location crossed in the direction of | travel. The movement is then clamped to that exact authored coordinate. | | Examples: | 45 -> 55, Location 50 => stop at 50 | 55 -> 45, Location 50 => stop at 50 | | The starting coordinate is intentionally exclusive so leaving a Location | does not immediately trigger that same Location again. | */ /* |-------------------------------------------------------------------------- | Location Conditions | WAYFARER_LOCATION_CONDITIONS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeLocationConditionsPass( PDO $pdo, int $gameId, array $location, array &$runtimeState ): bool { $config = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $conditions = $config['conditions'] ?? null; $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; return wayfarerConditionsPass( $conditions, $resolver ); } /* |-------------------------------------------------------------------------- | Game Endings | WAYFARER_GAME_ENDINGS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeEvaluateGameEndings( PDO $pdo, int $gameId, array &$runtimeState ): ?array { /* * WAYFARER_ALL_REGION_STATE_ENDINGS_V46 */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $runtimeState ); /* WAYFARER_RECOVER_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $runtimeState ); if ( isset( $runtimeState['runtime']['ending'] ) && is_array( $runtimeState['runtime']['ending'] ) ) { return $runtimeState['runtime']['ending']; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); $config = wayfarerRuntimeDecodeConfig( $raw === false ? null : $raw ); $endings = $config['endings'] ?? []; if (!is_array($endings)) { return null; } $ordered = []; foreach ( array_values($endings) as $index => $ending ) { if (!is_array($ending)) { continue; } $ending['_runtime_order'] = $index; $ordered[] = $ending; } usort( $ordered, function ( array $a, array $b ): int { $priorityCompare = (int)( $b['priority'] ?? 0 ) <=> (int)( $a['priority'] ?? 0 ); if ($priorityCompare !== 0) { return $priorityCompare; } return (int)( $a['_runtime_order'] ?? 0 ) <=> (int)( $b['_runtime_order'] ?? 0 ); } ); $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; foreach ($ordered as $ending) { if ( !wayfarerConditionsPass( $ending['conditions'] ?? null, $resolver ) ) { continue; } $terminal = [ 'id' => (string)( $ending['id'] ?? '' ), 'title' => (string)( $ending['title'] ?? 'The End' ), 'text' => (string)( $ending['text'] ?? '' ), 'image_media_id' => (int)( $ending['image_media_id'] ?? 0 ), 'priority' => (int)( $ending['priority'] ?? 0 ), 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), ]; if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['ending'] = $terminal; unset( $runtimeState['runtime']['presentation'] ); return $terminal; } return null; } function wayfarerRuntimeFindCrossedLocation( PDO $pdo, int $gameId, int $regionId, $fromPosition, $toPosition, array &$runtimeState ): ?array { /* * WAYFARER_CROSSED_LOCATION_STATE_FIX_V46_1 * * Location Conditions are evaluated against the live runtime state. */ if ( $gameId <= 0 || $regionId <= 0 || !is_numeric($fromPosition) || !is_numeric($toPosition) ) { return null; } $from = (float)$fromPosition; $to = (float)$toPosition; if ($from === $to) { return null; } /* * Crossing interception currently applies only to Linear Regions. */ $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); $forward = $to > $from; $candidate = null; $candidatePosition = null; foreach ($locations as $location) { $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $locationPosition = (float)$config['position']; /* * The starting point is exclusive; the destination is inclusive. */ $crossed = $forward ? ( $locationPosition > $from && $locationPosition <= $to ) : ( $locationPosition < $from && $locationPosition >= $to ); if (!$crossed) { continue; } $location['_runtime_config'] = $config; if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } if ($candidate === null) { $candidate = $location; $candidatePosition = $locationPosition; } else { $nearer = $forward ? ( $locationPosition < $candidatePosition ) : ( $locationPosition > $candidatePosition ); $samePositionLowerId = $locationPosition === $candidatePosition && (int)$location['id'] < (int)$candidate['id']; if ( $nearer || $samePositionLowerId ) { $candidate = $location; $candidatePosition = $locationPosition; } } } if ($candidate === null) { return null; } $candidate['_runtime_position'] = wayfarerNormalizeRuntimeNumber( (float)$candidatePosition ); $candidate['_runtime_config'] = wayfarerRuntimeDecodeConfig( $candidate['config'] ?? null ); return $candidate; } /* |-------------------------------------------------------------------------- | Player spatial context |-------------------------------------------------------------------------- | WAYFARER_PLAYER_CONTEXT_V46_1 | | This is the canonical player-facing spatial label for Action Mode: | | exact eligible Location -> Location name + Region parent context | otherwise -> Region name | | It is returned by runtime APIs so the browser does not keep displaying the | Location name from the page's original server render after movement. | */ function wayfarerRuntimePlayerContext( PDO $pdo, int $gameId, array $runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionName = ''; if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $rawRegionName = $stmt->fetchColumn(); if ($rawRegionName !== false) { $regionName = trim( (string)$rawRegionName ); } } $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); $locationId = 0; $locationName = ''; if ($location) { $locationId = (int)( $location['id'] ?? 0 ); $locationName = trim( (string)( $location['name'] ?? '' ) ); } $contextName = $locationName !== '' ? $locationName : ( $regionName !== '' ? $regionName : 'Actions' ); return [ 'region_id' => $regionId, 'region_name' => $regionName, 'location_id' => $locationId, 'location_name' => $locationName, 'context_name' => $contextName, 'parent_context' => $locationName !== '' ? $regionName : '', ]; } /* |-------------------------------------------------------------------------- | Presentation dismissal |-------------------------------------------------------------------------- | | Presentation is persisted runtime state. This function is intentionally | generic even though v40 renders only Location presentations; Storylets can | use the same mechanism later. | */ function wayfarerRuntimeDismissPresentation( PDO $pdo, int $gameUserId, string $expectedType = '', int $expectedId = 0 ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); $presentation = $state['runtime']['presentation'] ?? null; if ( !is_array($presentation) || empty($presentation['type']) || empty($presentation['id']) ) { throw new RuntimeException( 'There is no active presentation to continue.' ); } if ( $expectedType !== '' && (string)$presentation['type'] !== $expectedType ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } if ( $expectedId > 0 && (int)$presentation['id'] !== $expectedId ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } /* WAYFARER_STORYLET_BRANCH_DISMISS_GUARD_V59 */ if ( (string)($presentation['type'] ?? '') === 'storylet' ) { $eligibleBranches = wayfarerRuntimeEligibleStoryletBranches( $pdo, (int)$row['game_id'], (int)($presentation['id'] ?? 0), $state ); if ($eligibleBranches) { throw new RuntimeException( 'Choose an available Storylet Branch before continuing.' ); } } $state['runtime']['last_presentation'] = $presentation; unset( $state['runtime']['presentation'] ); /* WAYFARER_STATUS_STORYLET_QUEUE_V88 */ wayfarerRuntimePromotePendingStatusStorylet( $pdo, (int)$row['game_id'], $state ); wayfarerRuntimeEvaluateGameEndings( $pdo, (int)$row['game_id'], $state ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'dismissed' => $presentation, /* WAYFARER_STORYLET_ACTIONS_AFTER_PRESENTATION_V59 */ 'actions' => isset($state['runtime']['presentation']) && is_array($state['runtime']['presentation']) ? [] : wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$row['game_id'], $state ), /* WAYFARER_STATUS_STORYLET_QUEUE_V88 */ 'presentation' => isset($state['runtime']['presentation']) && is_array($state['runtime']['presentation']) ? $state['runtime']['presentation'] : null, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Advance exactly one tick. * * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ function wayfarerRuntimeAdvanceTick( PDO $pdo, int $gameUserId, ?int $requestedTravelDirection = null ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } /* WAYFARER_BIDIRECTIONAL_TRAVEL_V71 */ if ( !isset($state['navigation']) || !is_array($state['navigation']) ) { $state['navigation'] = []; } if ( $requestedTravelDirection !== null && !in_array( $requestedTravelDirection, [-1, 1], true ) ) { throw new RuntimeException( 'Travel direction must be -1 or 1.' ); } $state['navigation']['direction'] = $requestedTravelDirection !== null ? $requestedTravelDirection : wayfarerRuntimeNavigationDirection( $state ); $state['schema_version'] = max( 2, (int)( $state['schema_version'] ?? 1 ) ); /* * WAYFARER_GAME_ENDINGS_TERMINAL_V45 * * Endings are terminal runtime state. */ if ( isset( $state['runtime']['ending'] ) && is_array( $state['runtime']['ending'] ) ) { throw new RuntimeException( 'This game has ended.' ); } /* * Narrative presentation is a true runtime pause, not merely a hidden * browser control. Even Author Mode cannot advance ticks through it. */ if ( isset( $state['runtime']['presentation'] ) && is_array( $state['runtime']['presentation'] ) ) { throw new RuntimeException( 'Continue the current presentation before advancing time.' ); } /* * WAYFARER_ALL_REGION_STATE_TICK_V46 * * Recovery path for playthroughs initialized before v46, and for * Regions/Traits authored after a playthrough began. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_LOCATION_STATE_TICK_RECOVERY_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, (int)$row['game_id'], $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_TICK_V49 * * Recovery path for playthroughs created before V49 and for Entities * or Traits authored after play began. */ wayfarerRuntimeEnsureEntityState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_PLAYER_TRAITS_TICK_RECOVERY_V52 */ wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$row['game_id'], $state ); $regionIdBeforeTick = (int)( $state['navigation']['region_id'] ?? 0 ); $positionBeforeTick = $state['navigation']['position'] ?? null; $currentTick = (int)( $state['runtime']['tick'] ?? 0 ); $state['runtime']['tick'] = $currentTick + 1; /* WAYFARER_ADVANCE_GAME_CLOCK_V61 */ wayfarerRuntimeAdvanceClock( $pdo, (int)$row['game_id'], $state ); /* * Global Game Tick Effects run first. */ wayfarerApplyGameTickEffects( $pdo, (int)$row['game_id'], $state ); /* * Current Region Tick Effects then perform Region-level movement and * state changes. */ wayfarerApplyCurrentRegionTickEffects( $pdo, $state ); /* * Resolve whether the completed movement would have crossed an active * Location. If so, stop at the nearest crossed Location rather than * accepting the overshot position. */ $regionIdAfterMovement = (int)( $state['navigation']['region_id'] ?? 0 ); $positionAfterMovement = $state['navigation']['position'] ?? null; $crossedLocation = null; /* * We only interpret movement as a continuous path while the player * remains in the same Region. Region transitions will later get their * own explicit navigation semantics. */ if ( $regionIdBeforeTick > 0 && $regionIdBeforeTick === $regionIdAfterMovement ) { $crossedLocation = wayfarerRuntimeFindCrossedLocation( $pdo, (int)$row['game_id'], $regionIdAfterMovement, $positionBeforeTick, $positionAfterMovement, $state ); } $arrivalPosition = null; if ($crossedLocation) { $arrivalPosition = $crossedLocation[ '_runtime_position' ]; $state['navigation']['position'] = $arrivalPosition; /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Event Effects execute once at the trigger boundary. They run * before the Location's Tick Effects and before Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, (int)$row['game_id'], $crossedLocation, $state ); } /* * WAYFARER_LOCATION_EFFECTS_ONLY_RUNTIME_V43 * * Locations no longer own Tick Effects. Game and Region Tick Effects * have already advanced the simulation; a crossed Location contributes * its one-time Effects and then interrupts with Presentation Mode. */ if ($crossedLocation) { $state['navigation']['position'] = $arrivalPosition; $locationStoryletOpened = wayfarerRuntimePromotePendingLocationStorylet( $pdo, (int)$row['game_id'], $crossedLocation, $state, 'arrival' ); if (!$locationStoryletOpened) { $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$crossedLocation['id'], 'phase' => 'presentation', 'trigger' => 'arrival', 'tick' => (int)$state['runtime']['tick'], ]; } $state['runtime']['last_location_arrival'] = [ 'location_id' => (int)$crossedLocation['id'], 'region_id' => $regionIdAfterMovement, 'position' => $arrivalPosition, 'tick' => (int)$state['runtime']['tick'], 'storylet_transition' => $locationStoryletOpened, ]; } /* WAYFARER_STATUS_LIFECYCLE_TICK_V88 */ wayfarerRuntimeProcessStatusProgression( $pdo, (int)$row['game_id'], $state ); /* * A Location presentation keeps priority for the completed travel * tick. Status Storylets queue behind it and are promoted as soon as * there is no active presentation. */ wayfarerRuntimePromotePendingStatusStorylet( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_GAME_ENDINGS_AFTER_TICK_V45 */ $ending = wayfarerRuntimeEvaluateGameEndings( $pdo, (int)$row['game_id'], $state ); $state['runtime']['last_tick_at'] = gmdate('c'); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId ]); $pdo->commit(); $presentation = $state['runtime']['presentation'] ?? null; $pausedForPresentation = is_array($presentation) && !empty($presentation['type']) && !empty($presentation['id']); $ending = $state['runtime']['ending'] ?? null; $ended = is_array($ending) && !empty($ending['id']); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'tick' => (int)$state['runtime']['tick'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'pause' => $pausedForPresentation || $ended, 'pause_reason' => $ended ? 'Game ended.' : ( $pausedForPresentation ? ( (string)($presentation['type'] ?? '') === 'storylet' ? 'Storylet opened.' : 'Location reached.' ) : '' ), 'presentation' => $pausedForPresentation ? $presentation : null, 'ending' => $ended ? $ending : null, /* WAYFARER_STORYLET_ACTIONS_TICK_API_V59 */ 'actions' => !$pausedForPresentation && !$ended ? wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$row['game_id'], $state ) : [], /* WAYFARER_TRAVEL_TIME_HUD_V61 */ 'travel' => wayfarerRuntimeBuildTravelStatus( $pdo, (int)$row['game_id'], $state ), ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: play/includes/scene.php ================================================================================ prepare( " SELECT * FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $region = $stmt->fetch(PDO::FETCH_ASSOC); return $region ?: null; } function wayfarerV17LoadLocationAtPosition( PDO $pdo, int $regionId, $position ): ?array { $stmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $stmt->execute([ $regionId ]); $locations = $stmt->fetchAll(PDO::FETCH_ASSOC); foreach ($locations as $location) { $config = wayfarerV17DecodeConfig( $location['config'] ?? null ); if ( !array_key_exists('position', $config) || !is_numeric($config['position']) ) { continue; } if ( (float)$config['position'] === (float)$position ) { $location['_runtime_position'] = $config['position']; return $location; } } return null; } $sceneError = ''; $sceneGameUser = null; $sceneRegion = null; $sceneLocation = null; $scenePosition = 0; try { $requestedGameUserId = isset($_GET['game_user_id']) ? (int)$_GET['game_user_id'] : null; $sceneGameUserId = wayfarerRuntimeResolveGameUserId( $pdo, $requestedGameUserId ); $sceneGameUser = wayfarerRuntimeLoad( $pdo, $sceneGameUserId ); $sceneState = $sceneGameUser['state']; $sceneRegionId = (int)( $sceneState['navigation']['region_id'] ?? 0 ); $scenePosition = $sceneState['navigation']['position'] ?? 0; $sceneRegion = wayfarerV17LoadRegion( $pdo, (int)$sceneGameUser['game_id'], $sceneRegionId ); if ( $sceneRegion && ($sceneRegion['navigation_type'] ?? '') === 'linear' ) { $sceneLocation = wayfarerV17LoadLocationAtPosition( $pdo, (int)$sceneRegion['id'], $scenePosition ); } } catch (Throwable $exception) { $sceneError = $exception->getMessage(); } ?>

Current Scene

Current Scene

No current Region is available.


================================================================================ FILE: play/includes/setup.php ================================================================================ 0; } elseif ($selector === 'slot') { $slot = filter_var( $effect['target_slot'] ?? null, FILTER_VALIDATE_INT ); $validEffect = $slot !== false && $slot >= 0 && $slot < $playerSlots; if ($validEffect) { $effect['target_slot'] = (int)$slot; } } else { $validEffect = false; } } if (!$validEffect) { throw new RuntimeException( 'A Starting Choice contains an unsupported effect target.' ); } $effects[] = $effect; } } return [ 'player_names' => $playerNames, 'choices' => $selections, 'effects' => $effects, ]; } ================================================================================ FILE: play/includes/start-screen.php ================================================================================

0): ?>

$option ): ?>
================================================================================ FILE: play/includes/statuses.php ================================================================================ 0; } return in_array( strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on', 'active'], true ); } function wayfarerRuntimeStatusDefinitions( PDO $pdo, int $gameId, bool $activeOnly = true ): array { if ($gameId <= 0) { return []; } $sql = " SELECT id, name, description, config, status FROM wf_statuses WHERE game_id = ? "; if ($activeOnly) { $sql .= " AND status = 'active'"; } $sql .= " ORDER BY name, id"; $stmt = $pdo->prepare($sql); $stmt->execute([$gameId]); $definitions = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $config = []; if (!empty($row['config'])) { $decoded = json_decode((string)$row['config'], true); if (is_array($decoded)) { $config = $decoded; } } $showToPlayer = !array_key_exists('show_to_player', $config) || wayfarerRuntimeStatusNormalizeBoolean($config['show_to_player']); $showStageToPlayer = !array_key_exists('show_stage_to_player', $config) || wayfarerRuntimeStatusNormalizeBoolean($config['show_stage_to_player']); $showTimeRemaining = array_key_exists('show_time_remaining', $config) && wayfarerRuntimeStatusNormalizeBoolean($config['show_time_remaining']); $lifecycle = isset($config['lifecycle']) && is_array($config['lifecycle']) ? $config['lifecycle'] : null; /* WAYFARER_STATUS_OWNERS_RUNTIME_V92 */ $appliesTo = strtolower( trim((string)($config['applies_to'] ?? 'character')) ); if (!in_array($appliesTo, ['character', 'item', 'both'], true)) { $appliesTo = 'character'; } $definitions[(string)$id] = [ 'id' => $id, 'name' => trim((string)($row['name'] ?? ('Status ' . $id))), 'description' => trim((string)($row['description'] ?? '')), 'status' => strtolower(trim((string)($row['status'] ?? 'draft'))), 'applies_to' => $appliesTo, 'show_to_player' => $showToPlayer, 'show_stage_to_player' => $showStageToPlayer, 'show_time_remaining' => $showTimeRemaining, 'lifecycle' => $lifecycle, ]; } return $definitions; } function wayfarerRuntimeStatusDefinition( PDO $pdo, int $gameId, int $statusId, bool $activeOnly = true ): ?array { if ($statusId <= 0) { return null; } $definitions = wayfarerRuntimeStatusDefinitions( $pdo, $gameId, $activeOnly ); return $definitions[(string)$statusId] ?? null; } function wayfarerRuntimeStatusScopeAddress( array $runtimeState, string $scope ): ?array { $scope = strtolower(trim($scope)); if ( preg_match('/^player:([0-9]+)$/', $scope, $match) === 1 ) { $key = (string)((int)$match[1]); if (!isset($runtimeState['players'][$key]) || !is_array($runtimeState['players'][$key])) { return null; } return [ 'bucket' => 'players', 'key' => $key, ]; } if ( preg_match('/^entity:([1-9][0-9]*)$/', $scope, $match) === 1 ) { $key = (string)((int)$match[1]); if (!isset($runtimeState['entities'][$key]) || !is_array($runtimeState['entities'][$key])) { return null; } return [ 'bucket' => 'entities', 'key' => $key, ]; } return null; } /* WAYFARER_STATUS_OWNERS_RUNTIME_V92 */ function wayfarerRuntimeStatusOwnerType( array $runtimeState, string $scope, array $traitDefinitions = [] ): ?string { $scope = strtolower(trim($scope)); if (preg_match('/^player:[0-9]+$/', $scope) === 1) { return 'character'; } if (preg_match('/^entity:([1-9][0-9]*)$/', $scope, $match) !== 1) { return null; } $key = (string)((int)$match[1]); $definitions = isset($traitDefinitions['entities'][$key]) && is_array($traitDefinitions['entities'][$key]) ? $traitDefinitions['entities'][$key] : []; if (isset($definitions['sys_item_quantity'])) { return 'item'; } if (isset($definitions['sys_character_party_member'])) { return 'character'; } $traits = $runtimeState['entities'][$key]['traits'] ?? []; if (is_array($traits)) { if (array_key_exists('sys_item_quantity', $traits)) { return 'item'; } if (array_key_exists('sys_character_party_member', $traits)) { return 'character'; } } return null; } function wayfarerRuntimeStatusAppliesToOwner( array $definition, ?string $ownerType ): bool { if (!in_array($ownerType, ['character', 'item'], true)) { return false; } $appliesTo = strtolower(trim((string)($definition['applies_to'] ?? 'character'))); return $appliesTo === 'both' || $appliesTo === $ownerType; } function wayfarerRuntimeStatusState( array $runtimeState, string $scope, int $statusId ): array { $address = wayfarerRuntimeStatusScopeAddress( $runtimeState, $scope ); if ($address === null || $statusId <= 0) { return [ 'found_owner' => false, 'active' => false, 'started_tick' => null, 'stage_key' => null, 'stage_started_tick' => null, 'duration_ticks' => null, 'next_check_tick' => null, ]; } $stored = $runtimeState[$address['bucket']][$address['key']]['statuses'][(string)$statusId] ?? null; if (!is_array($stored)) { return [ 'found_owner' => true, 'active' => false, 'started_tick' => null, 'stage_key' => null, 'stage_started_tick' => null, 'duration_ticks' => null, 'next_check_tick' => null, ]; } return [ 'found_owner' => true, 'active' => !empty($stored['active']), 'started_tick' => isset($stored['started_tick']) && is_numeric($stored['started_tick']) ? (int)$stored['started_tick'] : null, 'stage_key' => trim((string)($stored['stage_key'] ?? '')) !== '' ? trim((string)$stored['stage_key']) : null, 'stage_started_tick' => isset($stored['stage_started_tick']) && is_numeric($stored['stage_started_tick']) ? (int)$stored['stage_started_tick'] : null, 'duration_ticks' => isset($stored['duration_ticks']) && is_numeric($stored['duration_ticks']) ? (int)$stored['duration_ticks'] : null, 'next_check_tick' => isset($stored['next_check_tick']) && is_numeric($stored['next_check_tick']) ? (int)$stored['next_check_tick'] : null, ]; } function wayfarerRuntimeSetStatus( array &$runtimeState, string $scope, int $statusId, bool $active ): array { $address = wayfarerRuntimeStatusScopeAddress( $runtimeState, $scope ); if ($address === null || $statusId <= 0) { return [ 'applied' => false, 'before' => null, 'after' => null, 'started_tick' => null, 'error' => 'Status owner was not found in runtime state.', ]; } $bucket = $address['bucket']; $key = $address['key']; if (!isset($runtimeState[$bucket][$key]['statuses']) || !is_array($runtimeState[$bucket][$key]['statuses'])) { $runtimeState[$bucket][$key]['statuses'] = []; } $statusKey = (string)$statusId; $existing = $runtimeState[$bucket][$key]['statuses'][$statusKey] ?? []; if (!is_array($existing)) { $existing = []; } $before = !empty($existing['active']); $startedTick = isset($existing['started_tick']) && is_numeric($existing['started_tick']) ? (int)$existing['started_tick'] : null; if ($active && !$before) { $startedTick = (int)( $runtimeState['runtime']['tick'] ?? 0 ); unset( $existing['stage_key'], $existing['stage_started_tick'], $existing['duration_ticks'], $existing['next_check_tick'], $existing['stage_effect_ticks'], $existing['last_outcome_key'], $existing['last_outcome_tick'] ); } $existing['active'] = $active; $existing['started_tick'] = $startedTick; $runtimeState[$bucket][$key]['statuses'][$statusKey] = $existing; return [ 'applied' => true, 'before' => $before ? 1 : 0, 'after' => $active ? 1 : 0, 'started_tick' => $startedTick, 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Timed lifecycle helpers | WAYFARER_STATUS_LIFECYCLE_RUNTIME_V88 |-------------------------------------------------------------------------- */ function wayfarerRuntimeStatusLifecycleStages( array $definition ): array { $lifecycle = isset($definition['lifecycle']) && is_array($definition['lifecycle']) ? $definition['lifecycle'] : []; if ( strtolower(trim((string)($lifecycle['mode'] ?? 'manual'))) !== 'progression' || !isset($lifecycle['stages']) || !is_array($lifecycle['stages']) ) { return []; } $stages = []; foreach (array_values($lifecycle['stages']) as $stage) { if (!is_array($stage)) { continue; } $key = strtolower(trim((string)($stage['key'] ?? ''))); if ($key === '') { continue; } $stage['key'] = $key; $stages[] = $stage; } return $stages; } function wayfarerRuntimeStatusLifecycleStageMap( array $definition ): array { $map = []; foreach (wayfarerRuntimeStatusLifecycleStages($definition) as $stage) { $map[(string)$stage['key']] = $stage; } return $map; } function wayfarerRuntimeStatusRollDuration( array $stage ): int { $duration = isset($stage['duration']) && is_array($stage['duration']) ? $stage['duration'] : []; $mode = strtolower(trim((string)($duration['mode'] ?? 'fixed'))); if ($mode === 'random') { $min = max(1, (int)($duration['min'] ?? 1)); $max = max($min, (int)($duration['max'] ?? $min)); return random_int($min, $max); } return max(1, (int)($duration['value'] ?? 1)); } function wayfarerRuntimeStatusInitializeLifecycle( array &$runtimeState, string $scope, int $statusId, array $definition ): array { $address = wayfarerRuntimeStatusScopeAddress($runtimeState, $scope); $stages = wayfarerRuntimeStatusLifecycleStages($definition); if ($address === null || $statusId <= 0 || $stages === []) { return [ 'initialized' => false, 'error' => '', ]; } $bucket = $address['bucket']; $key = $address['key']; $statusKey = (string)$statusId; $stored = $runtimeState[$bucket][$key]['statuses'][$statusKey] ?? null; if (!is_array($stored) || empty($stored['active'])) { return [ 'initialized' => false, 'error' => '', ]; } $stageMap = wayfarerRuntimeStatusLifecycleStageMap($definition); $stageKey = strtolower(trim((string)($stored['stage_key'] ?? ''))); $entered = false; if ($stageKey === '' || !isset($stageMap[$stageKey])) { $entered = true; $stageKey = (string)$stages[0]['key']; $stored['stage_key'] = $stageKey; $stored['stage_started_tick'] = isset($stored['started_tick']) && is_numeric($stored['started_tick']) ? (int)$stored['started_tick'] : (int)($runtimeState['runtime']['tick'] ?? 0); unset( $stored['duration_ticks'], $stored['next_check_tick'], $stored['stage_effect_ticks'] ); } if ( !isset($stored['stage_started_tick']) || !is_numeric($stored['stage_started_tick']) ) { $stored['stage_started_tick'] = (int)( $runtimeState['runtime']['tick'] ?? 0 ); } if ( !isset($stored['duration_ticks']) || !is_numeric($stored['duration_ticks']) || (int)$stored['duration_ticks'] < 1 ) { $stored['duration_ticks'] = wayfarerRuntimeStatusRollDuration( $stageMap[$stageKey] ); } if ( !isset($stored['next_check_tick']) || !is_numeric($stored['next_check_tick']) ) { $stored['next_check_tick'] = (int)$stored['stage_started_tick'] + (int)$stored['duration_ticks']; } $runtimeState[$bucket][$key]['statuses'][$statusKey] = $stored; return [ 'initialized' => true, 'entered' => $entered, 'stage_key' => $stageKey, 'stage_started_tick' => (int)$stored['stage_started_tick'], 'duration_ticks' => (int)$stored['duration_ticks'], 'next_check_tick' => (int)$stored['next_check_tick'], 'error' => '', ]; } function wayfarerRuntimeStatusEnterStage( array &$runtimeState, string $scope, int $statusId, array $definition, string $stageKey ): array { $address = wayfarerRuntimeStatusScopeAddress($runtimeState, $scope); $stageMap = wayfarerRuntimeStatusLifecycleStageMap($definition); $stageKey = strtolower(trim($stageKey)); if ( $address === null || $statusId <= 0 || $stageKey === '' || !isset($stageMap[$stageKey]) ) { return [ 'applied' => false, 'error' => 'Status Stage could not be resolved.', ]; } $bucket = $address['bucket']; $key = $address['key']; $statusKey = (string)$statusId; $stored = $runtimeState[$bucket][$key]['statuses'][$statusKey] ?? []; if (!is_array($stored)) { $stored = []; } $tick = (int)($runtimeState['runtime']['tick'] ?? 0); $duration = wayfarerRuntimeStatusRollDuration($stageMap[$stageKey]); $stored['active'] = true; if (!isset($stored['started_tick']) || !is_numeric($stored['started_tick'])) { $stored['started_tick'] = $tick; } $stored['stage_key'] = $stageKey; $stored['stage_started_tick'] = $tick; $stored['duration_ticks'] = $duration; $stored['next_check_tick'] = $tick + $duration; $stored['stage_effect_ticks'] = []; $runtimeState[$bucket][$key]['statuses'][$statusKey] = $stored; return [ 'applied' => true, 'stage_key' => $stageKey, 'stage_started_tick' => $tick, 'duration_ticks' => $duration, 'next_check_tick' => $tick + $duration, 'error' => '', ]; } function wayfarerRuntimeStatusChooseOutcome( array $stage ): ?array { $outcomes = isset($stage['outcomes']) && is_array($stage['outcomes']) ? array_values($stage['outcomes']) : []; if ($outcomes === []) { return null; } $roll = random_int(1, 100); $cursor = 0; foreach ($outcomes as $outcome) { if (!is_array($outcome)) { continue; } $chance = max(0, (int)($outcome['chance'] ?? 0)); $cursor += $chance; if ($roll <= $cursor) { $outcome['_runtime_roll'] = $roll; return $outcome; } } return null; } /* |-------------------------------------------------------------------------- | Status Owner Effects | WAYFARER_STATUS_SUBJECT_EFFECTS_RUNTIME_V89 |-------------------------------------------------------------------------- */ function wayfarerRuntimeStatusResolveSubjectTrait( array $runtimeState, array $traitDefinitions, string $scope, string $logicalTargetId ): array { if (!function_exists('wayfarerRuntimePartyDecodeTraitTargetId') || !function_exists('wayfarerRuntimePartyFindTraitDefinition')) { return [ 'found' => false, 'error' => 'Status Owner Trait resolver is unavailable.', ]; } $normalizedName = wayfarerRuntimePartyDecodeTraitTargetId( $logicalTargetId ); if ($normalizedName === null) { return [ 'found' => false, 'error' => 'Status Owner Trait target is invalid.', ]; } $definitions = []; $bucket = ''; $key = ''; if (preg_match('/^player:([0-9]+)$/', $scope, $match) === 1) { $bucket = 'players'; $key = (string)((int)$match[1]); $definitions = isset($traitDefinitions['player']) && is_array($traitDefinitions['player']) ? $traitDefinitions['player'] : []; } elseif (preg_match('/^entity:([1-9][0-9]*)$/', $scope, $match) === 1) { $bucket = 'entities'; $key = (string)((int)$match[1]); $definitions = isset($traitDefinitions['entities'][$key]) && is_array($traitDefinitions['entities'][$key]) ? $traitDefinitions['entities'][$key] : []; } else { return [ 'found' => false, 'error' => 'Status Owner scope is not a supported Character or Item.', ]; } $match = wayfarerRuntimePartyFindTraitDefinition( $definitions, $normalizedName ); if (!is_array($match)) { return [ 'found' => false, 'error' => 'Status Owner does not expose the selected Trait.', ]; } $traitId = trim((string)($match['id'] ?? '')); $definition = isset($match['definition']) && is_array($match['definition']) ? $match['definition'] : null; if ( $traitId === '' || !isset($runtimeState[$bucket][$key]['traits']) || !is_array($runtimeState[$bucket][$key]['traits']) || !array_key_exists($traitId, $runtimeState[$bucket][$key]['traits']) ) { return [ 'found' => false, 'error' => 'Status Owner runtime Trait was not found.', ]; } return [ 'found' => true, 'trait_id' => $traitId, 'definition' => $definition, 'before' => $runtimeState[$bucket][$key]['traits'][$traitId], 'error' => '', ]; } function wayfarerRuntimeStatusApplySubjectEffect( array &$runtimeState, array $effect, string $scope, int $currentRegionId, array $traitDefinitions ): array { $targetId = trim((string)($effect['target_id'] ?? '')); $operation = strtolower(trim((string)($effect['operation'] ?? ''))); $resolution = wayfarerRuntimeStatusResolveSubjectTrait( $runtimeState, $traitDefinitions, $scope, $targetId ); if (empty($resolution['found'])) { return [ 'scope' => $scope, 'trait_name' => (string)($effect['trait_name'] ?? ''), 'logical_target_id' => $targetId, 'operation' => $operation, 'applied' => false, 'error' => (string)($resolution['error'] ?? 'Status Owner Trait was not found.'), ]; } $rolled = function_exists('wayfarerRuntimeRollEffectValue') ? wayfarerRuntimeRollEffectValue($effect) : [ 'valid' => true, 'value' => $effect['value'] ?? 0, 'mode' => 'fixed', 'error' => '', ]; if (empty($rolled['valid'])) { return [ 'scope' => $scope, 'trait_name' => (string)($effect['trait_name'] ?? ''), 'logical_target_id' => $targetId, 'operation' => $operation, 'applied' => false, 'error' => (string)($rolled['error'] ?? 'Status Effect value could not be resolved.'), ]; } $authoredValue = $rolled['value'] ?? 0; $resolvedOperation = $operation; $resolvedValue = $authoredValue; $definition = isset($resolution['definition']) && is_array($resolution['definition']) ? $resolution['definition'] : null; if ( in_array( $operation, ['subtract_percent_current', 'subtract_percent_max', 'add_percent_max'], true ) ) { if (!is_numeric($authoredValue)) { return [ 'scope' => $scope, 'trait_name' => (string)($effect['trait_name'] ?? ''), 'logical_target_id' => $targetId, 'operation' => $operation, 'applied' => false, 'error' => 'Status percentage Effect requires a numeric percentage.', ]; } $percentage = (float)$authoredValue; if ($percentage < 0) { return [ 'scope' => $scope, 'trait_name' => (string)($effect['trait_name'] ?? ''), 'logical_target_id' => $targetId, 'operation' => $operation, 'applied' => false, 'error' => 'Status percentage Effect cannot use a negative percentage.', ]; } if ($operation === 'subtract_percent_current') { if (!is_numeric($resolution['before'] ?? null)) { return [ 'scope' => $scope, 'trait_name' => (string)($effect['trait_name'] ?? ''), 'logical_target_id' => $targetId, 'operation' => $operation, 'applied' => false, 'error' => 'Subtract % of Current requires a numeric current Trait value.', ]; } $resolvedOperation = 'subtract'; $resolvedValue = wayfarerNormalizeRuntimeNumber( ((float)$resolution['before'] * $percentage) / 100 ); } else { $maximum = is_array($definition) ? ($definition['max'] ?? null) : null; if (!is_numeric($maximum)) { return [ 'scope' => $scope, 'trait_name' => (string)($effect['trait_name'] ?? ''), 'logical_target_id' => $targetId, 'operation' => $operation, 'applied' => false, 'error' => 'Percentage of Maximum requires the Status Owner Trait to have a numeric Maximum.', ]; } $resolvedOperation = $operation === 'add_percent_max' ? 'add' : 'subtract'; $resolvedValue = wayfarerNormalizeRuntimeNumber( ((float)$maximum * $percentage) / 100 ); } } $runtimeEffect = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => (string)$resolution['trait_id'], 'operation' => $resolvedOperation, 'value' => $resolvedValue, ]; $report = wayfarerApplyRuntimeEffect( $runtimeState, $runtimeEffect, $currentRegionId, $traitDefinitions ); $report['status_effect_key'] = (string)($effect['key'] ?? ''); $report['status_subject_scope'] = $scope; $report['status_owner_scope'] = $scope; $report['trait_name'] = (string)($effect['trait_name'] ?? ''); $report['logical_target_id'] = $targetId; $report['authored_operation'] = $operation; $report['authored_value'] = $authoredValue; $report['resolved_operation'] = $resolvedOperation; $report['resolved_value'] = $resolvedValue; return $report; } function wayfarerRuntimeStatusApplySubjectEffects( array &$runtimeState, array $effects, string $scope, int $currentRegionId, array $traitDefinitions ): array { $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { continue; } $reports[] = wayfarerRuntimeStatusApplySubjectEffect( $runtimeState, $effect, $scope, $currentRegionId, $traitDefinitions ); } return $reports; } /* |-------------------------------------------------------------------------- | Immediate lifecycle activation | WAYFARER_STATUS_STORYLET_ACTIVATION_V92 |-------------------------------------------------------------------------- */ function wayfarerRuntimeInitializeActivatedStatus( PDO $pdo, int $gameId, array &$runtimeState, string $scope, int $statusId, int $currentRegionId, array $traitDefinitions ): array { $definition = wayfarerRuntimeStatusDefinition( $pdo, $gameId, $statusId, true ); if (!is_array($definition)) { return [ 'initialized' => false, 'entry_effects' => [], 'error' => 'The selected Status definition is not Active.', ]; } $ownerType = wayfarerRuntimeStatusOwnerType( $runtimeState, $scope, $traitDefinitions ); if (!wayfarerRuntimeStatusAppliesToOwner($definition, $ownerType)) { return [ 'initialized' => false, 'entry_effects' => [], 'error' => 'The selected Status does not apply to this Status Owner type.', ]; } $init = wayfarerRuntimeStatusInitializeLifecycle( $runtimeState, $scope, $statusId, $definition ); $entryReports = []; if (!empty($init['initialized']) && !empty($init['entered'])) { $stageMap = wayfarerRuntimeStatusLifecycleStageMap($definition); $stageKey = (string)($init['stage_key'] ?? ''); $stage = $stageMap[$stageKey] ?? null; if (is_array($stage)) { $entryReports = wayfarerRuntimeStatusApplySubjectEffects( $runtimeState, isset($stage['entry_effects']) && is_array($stage['entry_effects']) ? array_values($stage['entry_effects']) : [], $scope, $currentRegionId, $traitDefinitions ); } } return [ 'initialized' => !empty($init['initialized']), 'entered' => !empty($init['entered']), 'stage_key' => $init['stage_key'] ?? null, 'entry_effects' => $entryReports, 'error' => (string)($init['error'] ?? ''), ]; } function wayfarerRuntimeStatusApplyRecurringEffects( array &$runtimeState, string $scope, int $statusId, array $stage, int $tick, int $currentRegionId, array $traitDefinitions ): array { $effects = isset($stage['tick_effects']) && is_array($stage['tick_effects']) ? array_values($stage['tick_effects']) : []; if ($effects === []) { return []; } $address = wayfarerRuntimeStatusScopeAddress($runtimeState, $scope); if ($address === null) { return []; } $statusKey = (string)$statusId; $stored =& $runtimeState[$address['bucket']][$address['key']]['statuses'][$statusKey]; if (!is_array($stored) || empty($stored['active'])) { unset($stored); return []; } $stageStartedTick = isset($stored['stage_started_tick']) && is_numeric($stored['stage_started_tick']) ? (int)$stored['stage_started_tick'] : $tick; $elapsed = $tick - $stageStartedTick; if ($elapsed <= 0) { unset($stored); return []; } if (!isset($stored['stage_effect_ticks']) || !is_array($stored['stage_effect_ticks'])) { $stored['stage_effect_ticks'] = []; } $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { continue; } $interval = max(1, (int)($effect['interval_ticks'] ?? 1)); if ($elapsed % $interval !== 0) { continue; } $effectKey = trim((string)($effect['key'] ?? '')); if ($effectKey === '') { continue; } if ( isset($stored['stage_effect_ticks'][$effectKey]) && (int)$stored['stage_effect_ticks'][$effectKey] === $tick ) { continue; } $report = wayfarerRuntimeStatusApplySubjectEffect( $runtimeState, $effect, $scope, $currentRegionId, $traitDefinitions ); $report['interval_ticks'] = $interval; $report['elapsed_stage_ticks'] = $elapsed; $reports[] = $report; $stored['stage_effect_ticks'][$effectKey] = $tick; } unset($stored); return $reports; } function wayfarerRuntimeQueueStatusStorylet( array &$runtimeState, array $event ): void { if (!isset($runtimeState['runtime']) || !is_array($runtimeState['runtime'])) { $runtimeState['runtime'] = []; } if ( !isset($runtimeState['runtime']['pending_status_storylets']) || !is_array($runtimeState['runtime']['pending_status_storylets']) ) { $runtimeState['runtime']['pending_status_storylets'] = []; } $runtimeState['runtime']['pending_status_storylets'][] = $event; } function wayfarerRuntimePromotePendingStatusStorylet( PDO $pdo, int $gameId, array &$runtimeState ): ?array { if ( isset($runtimeState['runtime']['presentation']) && is_array($runtimeState['runtime']['presentation']) ) { return null; } if ( !isset($runtimeState['runtime']['pending_status_storylets']) || !is_array($runtimeState['runtime']['pending_status_storylets']) || $runtimeState['runtime']['pending_status_storylets'] === [] ) { return null; } while ($runtimeState['runtime']['pending_status_storylets'] !== []) { $event = array_shift( $runtimeState['runtime']['pending_status_storylets'] ); if (!is_array($event)) { continue; } $storyletId = (int)($event['storylet_id'] ?? 0); $subjectScope = strtolower(trim((string)($event['subject_scope'] ?? ''))); if ($storyletId <= 0 || $subjectScope === '') { continue; } try { $storylet = wayfarerRuntimeLoadStorylet( $pdo, $gameId, $storyletId ); if (!$storylet) { throw new RuntimeException( 'Status Outcome Storylet could not be loaded.' ); } $subjectResult = wayfarerRuntimeStoryletPresetStatusOwnerBinding( $pdo, $gameId, $storylet, $runtimeState, $subjectScope, 'subject', 'Subject' ); if (($subjectResult['error'] ?? '') !== '') { throw new RuntimeException( (string)$subjectResult['error'] ); } $binding = $subjectResult['binding'] ?? null; if (!is_array($binding)) { throw new RuntimeException( 'Status Outcome Storylet subject could not be prepared.' ); } $result = wayfarerRuntimeEnterTriggeredStoryletInState( $pdo, $gameId, $storyletId, $runtimeState, [ 'subject' => $binding, ], 'status' ); $runtimeState['runtime']['last_status_storylet'] = [ 'storylet_id' => $storyletId, 'subject_scope' => $subjectScope, 'status_id' => (int)($event['status_id'] ?? 0), 'outcome_key' => (string)($event['outcome_key'] ?? ''), 'tick' => (int)($runtimeState['runtime']['tick'] ?? 0), ]; if ($runtimeState['runtime']['pending_status_storylets'] === []) { unset($runtimeState['runtime']['pending_status_storylets']); } return $result; } catch (Throwable $exception) { $runtimeState['runtime']['last_status_storylet_error'] = [ 'storylet_id' => $storyletId, 'subject_scope' => $subjectScope, 'message' => $exception->getMessage(), 'tick' => (int)($runtimeState['runtime']['tick'] ?? 0), ]; } } unset($runtimeState['runtime']['pending_status_storylets']); return null; } function wayfarerRuntimeProcessStatusProgression( PDO $pdo, int $gameId, array &$runtimeState ): array { $definitions = wayfarerRuntimeStatusDefinitions( $pdo, $gameId, true ); if ($definitions === []) { return []; } $tick = (int)($runtimeState['runtime']['tick'] ?? 0); $currentRegionId = (int)($runtimeState['navigation']['region_id'] ?? 0); $traitDefinitions = function_exists('wayfarerRuntimeLoadTickTraitDefinitions') ? wayfarerRuntimeLoadTickTraitDefinitions($pdo, $gameId, []) : []; $reports = []; $owners = []; foreach ( isset($runtimeState['players']) && is_array($runtimeState['players']) ? $runtimeState['players'] : [] as $playerKey => $playerState ) { if (is_array($playerState)) { $owners[] = 'player:' . (string)$playerKey; } } foreach ( isset($runtimeState['entities']) && is_array($runtimeState['entities']) ? $runtimeState['entities'] : [] as $entityKey => $entityState ) { if (is_array($entityState)) { $owners[] = 'entity:' . (string)$entityKey; } } foreach ($owners as $scope) { $address = wayfarerRuntimeStatusScopeAddress($runtimeState, $scope); if ($address === null) { continue; } $storedStatuses = $runtimeState[$address['bucket']][$address['key']]['statuses'] ?? []; if (!is_array($storedStatuses) || $storedStatuses === []) { continue; } foreach (array_keys($storedStatuses) as $statusKey) { $statusId = (int)$statusKey; $definition = $definitions[(string)$statusId] ?? null; if (!is_array($definition)) { continue; } /* WAYFARER_STATUS_OWNERS_RUNTIME_V92 */ $ownerType = wayfarerRuntimeStatusOwnerType( $runtimeState, $scope, $traitDefinitions ); if (!wayfarerRuntimeStatusAppliesToOwner($definition, $ownerType)) { continue; } if (wayfarerRuntimeStatusLifecycleStages($definition) === []) { continue; } $init = wayfarerRuntimeStatusInitializeLifecycle( $runtimeState, $scope, $statusId, $definition ); if (empty($init['initialized'])) { continue; } $state = wayfarerRuntimeStatusState( $runtimeState, $scope, $statusId ); if (empty($state['active'])) { continue; } $stageMap = wayfarerRuntimeStatusLifecycleStageMap($definition); $stageKey = (string)($state['stage_key'] ?? ''); $stage = $stageMap[$stageKey] ?? null; if (!is_array($stage)) { continue; } $entryEffectReports = []; if (!empty($init['entered'])) { $entryEffectReports = wayfarerRuntimeStatusApplySubjectEffects( $runtimeState, isset($stage['entry_effects']) && is_array($stage['entry_effects']) ? array_values($stage['entry_effects']) : [], $scope, $currentRegionId, $traitDefinitions ); } $tickEffectReports = wayfarerRuntimeStatusApplyRecurringEffects( $runtimeState, $scope, $statusId, $stage, $tick, $currentRegionId, $traitDefinitions ); if ( $state['next_check_tick'] === null || (int)$state['next_check_tick'] > $tick ) { if ($entryEffectReports !== [] || $tickEffectReports !== []) { $reports[] = [ 'event' => 'stage_effects', 'scope' => $scope, 'status_id' => $statusId, 'status_name' => (string)($definition['name'] ?? ''), 'stage_key' => $stageKey, 'stage_name' => (string)($stage['name'] ?? $stageKey), 'entry_effects' => $entryEffectReports, 'tick_effects' => $tickEffectReports, 'tick' => $tick, ]; } continue; } $outcome = wayfarerRuntimeStatusChooseOutcome($stage); if (!is_array($outcome)) { continue; } /* Outcome Effects apply before the Result changes the Status Stage. */ $outcomeEffectReports = wayfarerRuntimeStatusApplySubjectEffects( $runtimeState, isset($outcome['effects']) && is_array($outcome['effects']) ? array_values($outcome['effects']) : [], $scope, $currentRegionId, $traitDefinitions ); $result = strtolower(trim((string)($outcome['result'] ?? 'inactive'))); $resultError = ''; $nextEntryEffectReports = []; if ($result === 'inactive') { $statusResult = wayfarerRuntimeSetStatus( $runtimeState, $scope, $statusId, false ); $resultError = (string)($statusResult['error'] ?? ''); } elseif ($result === 'stay') { $stageResult = wayfarerRuntimeStatusEnterStage( $runtimeState, $scope, $statusId, $definition, $stageKey ); $resultError = (string)($stageResult['error'] ?? ''); if ($resultError === '') { $nextEntryEffectReports = wayfarerRuntimeStatusApplySubjectEffects( $runtimeState, isset($stage['entry_effects']) && is_array($stage['entry_effects']) ? array_values($stage['entry_effects']) : [], $scope, $currentRegionId, $traitDefinitions ); } } elseif (strpos($result, 'stage:') === 0) { $nextStageKey = substr($result, 6); $stageResult = wayfarerRuntimeStatusEnterStage( $runtimeState, $scope, $statusId, $definition, $nextStageKey ); $resultError = (string)($stageResult['error'] ?? ''); if ($resultError === '' && isset($stageMap[$nextStageKey])) { $nextStage = $stageMap[$nextStageKey]; $nextEntryEffectReports = wayfarerRuntimeStatusApplySubjectEffects( $runtimeState, isset($nextStage['entry_effects']) && is_array($nextStage['entry_effects']) ? array_values($nextStage['entry_effects']) : [], $scope, $currentRegionId, $traitDefinitions ); } } else { $resultError = 'Status Outcome Result is invalid.'; } if ($resultError !== '') { throw new RuntimeException( 'Status progression failed for ' . (string)($definition['name'] ?? ('Status ' . $statusId)) . ': ' . $resultError ); } $afterAddress = wayfarerRuntimeStatusScopeAddress($runtimeState, $scope); if ($afterAddress !== null) { $afterStored =& $runtimeState[$afterAddress['bucket']][$afterAddress['key']]['statuses'][(string)$statusId]; if (is_array($afterStored)) { $afterStored['last_outcome_key'] = (string)($outcome['key'] ?? ''); $afterStored['last_outcome_tick'] = $tick; } unset($afterStored); } $presentation = strtolower(trim((string)($outcome['presentation'] ?? 'silent'))); $storyletId = (int)($outcome['storylet_id'] ?? 0); if ($presentation === 'storylet' && $storyletId > 0) { wayfarerRuntimeQueueStatusStorylet( $runtimeState, [ 'storylet_id' => $storyletId, 'subject_scope' => $scope, 'status_id' => $statusId, 'status_name' => (string)($definition['name'] ?? ''), 'stage_key' => $stageKey, 'stage_name' => (string)($stage['name'] ?? $stageKey), 'outcome_key' => (string)($outcome['key'] ?? ''), 'outcome_label' => (string)($outcome['label'] ?? ''), 'tick' => $tick, ] ); } $reports[] = [ 'event' => 'outcome', 'scope' => $scope, 'status_id' => $statusId, 'status_name' => (string)($definition['name'] ?? ''), 'stage_key' => $stageKey, 'stage_name' => (string)($stage['name'] ?? $stageKey), 'entry_effects' => $entryEffectReports, 'tick_effects' => $tickEffectReports, 'outcome_effects' => $outcomeEffectReports, 'next_stage_entry_effects' => $nextEntryEffectReports, 'outcome_key' => (string)($outcome['key'] ?? ''), 'outcome_label' => (string)($outcome['label'] ?? ''), 'result' => $result, 'roll' => (int)($outcome['_runtime_roll'] ?? 0), 'presentation' => $presentation, 'storylet_id' => $storyletId, 'tick' => $tick, ]; } } if ($reports !== []) { if (!isset($runtimeState['runtime']) || !is_array($runtimeState['runtime'])) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_status_progression'] = [ 'tick' => $tick, 'reports' => $reports, ]; } return $reports; } function wayfarerRuntimeVisibleStatusesForScope( PDO $pdo, int $gameId, array $runtimeState, string $scope ): array { $address = wayfarerRuntimeStatusScopeAddress( $runtimeState, $scope ); if ($address === null) { return []; } $definitions = wayfarerRuntimeStatusDefinitions( $pdo, $gameId, true ); if ($definitions === []) { return []; } $storedStatuses = $runtimeState[$address['bucket']][$address['key']]['statuses'] ?? []; if (!is_array($storedStatuses)) { return []; } $visible = []; $currentTick = (int)($runtimeState['runtime']['tick'] ?? 0); /* WAYFARER_STATUS_OWNER_VISIBILITY_V92 */ $ownerType = wayfarerRuntimeStatusOwnerType( $runtimeState, $scope, [] ); foreach ($definitions as $statusKey => $definition) { if ( !wayfarerRuntimeStatusAppliesToOwner( $definition, $ownerType ) ) { continue; } if (empty($definition['show_to_player'])) { continue; } $stored = $storedStatuses[$statusKey] ?? null; if (!is_array($stored) || empty($stored['active'])) { continue; } $stageName = ''; $remainingTicks = null; if (wayfarerRuntimeStatusLifecycleStages($definition) !== []) { $stageMap = wayfarerRuntimeStatusLifecycleStageMap($definition); $stageKey = strtolower(trim((string)($stored['stage_key'] ?? ''))); if ($stageKey === '') { $stages = wayfarerRuntimeStatusLifecycleStages($definition); $stageKey = (string)($stages[0]['key'] ?? ''); } if ( !empty($definition['show_stage_to_player']) && isset($stageMap[$stageKey]) ) { $stageName = trim((string)($stageMap[$stageKey]['name'] ?? $stageKey)); } if ( !empty($definition['show_time_remaining']) && isset($stored['next_check_tick']) && is_numeric($stored['next_check_tick']) ) { $remainingTicks = max( 0, (int)$stored['next_check_tick'] - $currentTick ); } } $visible[] = [ 'id' => (int)$definition['id'], 'name' => (string)$definition['name'], 'description' => (string)$definition['description'], 'started_tick' => isset($stored['started_tick']) && is_numeric($stored['started_tick']) ? (int)$stored['started_tick'] : null, 'stage_name' => $stageName, 'remaining_ticks' => $remainingTicks, ]; } return $visible; } ================================================================================ FILE: play/includes/storylet-bindings.php ================================================================================ $key, 'label' => $label !== '' ? $label : $key, 'selector' => $selector, ]; } return $bindings; } function wayfarerRuntimeStoryletBindingRequiredTargets( array $storylet, string $bindingKey ): array { $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); $effects = isset($config['effects']) && is_array($config['effects']) ? $config['effects'] : []; $scope = 'binding:' . $bindingKey; $required = []; foreach ($effects as $effect) { if (!is_array($effect)) { continue; } if ( strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ) !== $scope ) { continue; } $targetType = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); if ( $targetType === '' || $targetId === '' ) { continue; } $required[ $targetType . ':' . $targetId ] = [ 'target_type' => $targetType, 'target_id' => $targetId, ]; } return array_values($required); } function wayfarerRuntimeStoryletBindingFindNamedTrait( array $config, string $normalizedName ): ?array { $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; $match = null; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) || ( array_key_exists( 'active', $trait ) && !$trait['active'] ) ) { continue; } $name = wayfarerRuntimeStoryletBindingNormalizeTraitName( (string)( $trait['name'] ?? '' ) ); if ($name !== $normalizedName) { continue; } /* * Ambiguous duplicate names on one Entity are not safe for a generic * binding target. */ if ($match !== null) { return null; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { return null; } $match = $trait; } return $match; } function wayfarerRuntimeStoryletBindingEntitySupportsTargets( array $entity, array $requiredTargets ): array { $config = wayfarerEntityDecodeConfig( $entity['config'] ?? null ); $targetMap = []; foreach ($requiredTargets as $required) { if (!is_array($required)) { continue; } $targetType = strtolower( trim( (string)( $required['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $required['target_id'] ?? '' ) ); if ( $targetType === 'status' && preg_match('/^[1-9][0-9]*$/', $targetId) === 1 ) { $targetMap[$targetId] = $targetId; continue; } if ( $targetType !== 'trait' || $targetId === '' ) { return [ 'supported' => false, 'target_map' => [], ]; } if ( $targetId === 'sys_item_quantity' || $targetId === 'sys_character_party_member' ) { $targetMap[$targetId] = $targetId; continue; } $namedTrait = wayfarerRuntimeStoryletBindingDecodeNamedTraitId( $targetId ); if ($namedTrait === null) { return [ 'supported' => false, 'target_map' => [], ]; } $trait = wayfarerRuntimeStoryletBindingFindNamedTrait( $config, $namedTrait ); if (!is_array($trait)) { return [ 'supported' => false, 'target_map' => [], ]; } $actualTraitId = trim( (string)( $trait['id'] ?? '' ) ); if ($actualTraitId === '') { return [ 'supported' => false, 'target_map' => [], ]; } $targetMap[$targetId] = $actualTraitId; } return [ 'supported' => true, 'target_map' => $targetMap, ]; } /* WAYFARER_PARTY_BINDINGS_V85 */ function wayfarerRuntimeStoryletBindingFindPlayerTrait( array $gameConfig, string $normalizedName ): ?array { $traits = isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : []; $match = null; foreach ($traits as $trait) { if ( !is_array($trait) || ( array_key_exists('active', $trait) && !$trait['active'] ) ) { continue; } $name = wayfarerRuntimeStoryletBindingNormalizeTraitName( (string)( $trait['name'] ?? '' ) ); if ($name !== $normalizedName) { continue; } if ($match !== null) { return null; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { return null; } $match = $trait; } return $match; } function wayfarerRuntimeStoryletBindingPlayerSupportsTargets( array $gameConfig, array $playerState, array $requiredTargets ): array { $targetMap = []; $runtimeTraits = isset($playerState['traits']) && is_array($playerState['traits']) ? $playerState['traits'] : []; foreach ($requiredTargets as $required) { if (!is_array($required)) { continue; } $targetType = strtolower( trim( (string)( $required['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $required['target_id'] ?? '' ) ); if ( $targetType === 'status' && preg_match('/^[1-9][0-9]*$/', $targetId) === 1 ) { $targetMap[$targetId] = $targetId; continue; } if ( $targetType !== 'trait' || $targetId === '' || $targetId === 'sys_item_quantity' || $targetId === 'sys_character_party_member' ) { return [ 'supported' => false, 'target_map' => [], ]; } $namedTrait = wayfarerRuntimeStoryletBindingDecodeNamedTraitId( $targetId ); if ($namedTrait === null) { return [ 'supported' => false, 'target_map' => [], ]; } $trait = wayfarerRuntimeStoryletBindingFindPlayerTrait( $gameConfig, $namedTrait ); if (!is_array($trait)) { return [ 'supported' => false, 'target_map' => [], ]; } $actualTraitId = trim( (string)( $trait['id'] ?? '' ) ); if ( $actualTraitId === '' || !array_key_exists( $actualTraitId, $runtimeTraits ) ) { return [ 'supported' => false, 'target_map' => [], ]; } $targetMap[$targetId] = $actualTraitId; } return [ 'supported' => true, 'target_map' => $targetMap, ]; } /* WAYFARER_STATUS_SUBJECT_BINDING_V88 */ /* WAYFARER_STATUS_OWNER_BINDING_V92 */ function wayfarerRuntimeStoryletPresetCharacterBinding( PDO $pdo, int $gameId, array $storylet, array $runtimeState, string $runtimeScope, string $bindingKey = 'subject', string $label = 'Subject' ): array { $runtimeScope = strtolower(trim($runtimeScope)); $bindingKey = wayfarerRuntimeStoryletBindingNormalizeKey($bindingKey); if ( $bindingKey === '' || preg_match('/^[a-z][a-z0-9_]{0,31}$/', $bindingKey) !== 1 ) { return [ 'binding' => null, 'error' => 'Triggered Storylet subject binding key is invalid.', ]; } $requiredTargets = wayfarerRuntimeStoryletBindingRequiredTargets( $storylet, $bindingKey ); if ( preg_match('/^player:([0-9]+)$/', $runtimeScope, $match) === 1 ) { $slot = (int)$match[1]; $playerState = $runtimeState['players'][(string)$slot] ?? null; if (!is_array($playerState)) { return [ 'binding' => null, 'error' => 'Triggered Storylet Player subject was not found.', ]; } $gameConfig = function_exists('wayfarerRuntimeLoadGameConfig') ? wayfarerRuntimeLoadGameConfig($pdo, $gameId) : []; $support = wayfarerRuntimeStoryletBindingPlayerSupportsTargets( $gameConfig, $playerState, $requiredTargets ); if (empty($support['supported'])) { return [ 'binding' => null, 'error' => 'Triggered Storylet subject does not expose every Trait/Status required by the subject Binding Effects.', ]; } $name = trim((string)($playerState['name'] ?? '')); if ($name === '') { $name = 'Player ' . ($slot + 1); } return [ 'binding' => [ 'key' => $bindingKey, 'label' => $label, 'selector' => 'status_subject', 'runtime_scope' => 'player:' . $slot, 'subject_kind' => 'player', 'player_slot' => $slot, 'entity_id' => 0, 'entity_type' => 'player', 'name' => $name, 'target_map' => $support['target_map'] ?? [], ], 'error' => '', ]; } if ( preg_match('/^entity:([1-9][0-9]*)$/', $runtimeScope, $match) === 1 ) { $entityId = (int)$match[1]; $runtimeEntity = $runtimeState['entities'][(string)$entityId] ?? null; if (!is_array($runtimeEntity)) { return [ 'binding' => null, 'error' => 'Triggered Storylet Status Owner was not found.', ]; } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $entityId, $gameId, ]); $entity = $stmt->fetch(PDO::FETCH_ASSOC); if (!$entity) { return [ 'binding' => null, 'error' => 'Triggered Storylet Status Owner definition was not found.', ]; } $support = wayfarerRuntimeStoryletBindingEntitySupportsTargets( $entity, $requiredTargets ); if (empty($support['supported'])) { return [ 'binding' => null, 'error' => 'Triggered Storylet subject does not expose every Trait/Status required by the subject Binding Effects.', ]; } return [ 'binding' => [ 'key' => $bindingKey, 'label' => $label, 'selector' => 'status_subject', 'runtime_scope' => 'entity:' . $entityId, 'subject_kind' => 'entity', 'player_slot' => null, 'entity_id' => $entityId, 'entity_type' => strtolower(trim((string)($entity['entity_type'] ?? 'character'))), 'name' => trim((string)($entity['name'] ?? ('Character ' . $entityId))), 'target_map' => $support['target_map'] ?? [], ], 'error' => '', ]; } return [ 'binding' => null, 'error' => 'Triggered Storylet subject scope is invalid.', ]; } /* * Generic V92 name. The V88 implementation already resolves both Player scopes * and Entity scopes; V92 formally permits Item Entities as Status Owners too. */ function wayfarerRuntimeStoryletPresetStatusOwnerBinding( PDO $pdo, int $gameId, array $storylet, array $runtimeState, string $runtimeScope, string $bindingKey = 'subject', string $label = 'Subject' ): array { return wayfarerRuntimeStoryletPresetCharacterBinding( $pdo, $gameId, $storylet, $runtimeState, $runtimeScope, $bindingKey, $label ); } function wayfarerRuntimeStoryletBindingCandidates( PDO $pdo, int $gameId, array $storylet, array $binding, array $runtimeState ): array { $selector = strtolower( trim( (string)( $binding['selector'] ?? '' ) ) ); $bindingKey = wayfarerRuntimeStoryletBindingNormalizeKey( (string)( $binding['key'] ?? '' ) ); $requiredTargets = wayfarerRuntimeStoryletBindingRequiredTargets( $storylet, $bindingKey ); $candidates = []; /* * Named setup Player Characters are party members too. They do not use the * Entity Party Membership Trait; their presence in runtime.players is the * canonical membership signal for the playthrough. */ if ($selector === 'random_party_character') { $gameConfig = function_exists('wayfarerRuntimeLoadGameConfig') ? wayfarerRuntimeLoadGameConfig( $pdo, $gameId ) : []; $players = isset($runtimeState['players']) && is_array($runtimeState['players']) ? $runtimeState['players'] : []; foreach ($players as $playerKey => $playerState) { if (!is_array($playerState)) { continue; } $name = trim( (string)( $playerState['name'] ?? '' ) ); if ($name === '') { continue; } $support = wayfarerRuntimeStoryletBindingPlayerSupportsTargets( $gameConfig, $playerState, $requiredTargets ); if (empty($support['supported'])) { continue; } $slot = (int)( $playerState['slot'] ?? $playerKey ); $candidates[] = [ 'runtime_scope' => 'player:' . $slot, 'subject_kind' => 'player', 'player_slot' => $slot, 'entity_id' => 0, 'entity_type' => 'player', 'name' => $name, 'target_map' => $support['target_map'], ]; } } foreach ( wayfarerRuntimeLoadActiveEntities( $pdo, $gameId ) as $entity ) { if (!is_array($entity)) { continue; } $entityId = (int)( $entity['id'] ?? 0 ); if ($entityId <= 0) { continue; } $entityType = strtolower( trim( (string)( $entity['entity_type'] ?? '' ) ) ); $entityState = $runtimeState['entities'][ (string)$entityId ] ?? null; if (!is_array($entityState)) { continue; } $traits = isset($entityState['traits']) && is_array($entityState['traits']) ? $entityState['traits'] : []; if ( $selector === 'random_party_character' ) { if ( $entityType !== 'character' || empty( $traits[ 'sys_character_party_member' ] ) ) { continue; } } elseif ( $selector === 'random_possessed_item' ) { $quantity = $traits[ 'sys_item_quantity' ] ?? 0; if ( $entityType !== 'item' || !is_numeric($quantity) || (float)$quantity <= 0 ) { continue; } } else { continue; } $support = wayfarerRuntimeStoryletBindingEntitySupportsTargets( $entity, $requiredTargets ); if (empty($support['supported'])) { continue; } $candidates[] = [ 'runtime_scope' => 'entity:' . $entityId, 'subject_kind' => 'entity', 'player_slot' => null, 'entity_id' => $entityId, 'entity_type' => $entityType, 'name' => trim( (string)( $entity['name'] ?? ('Entity ' . $entityId) ) ), 'target_map' => $support['target_map'], ]; } return $candidates; } function wayfarerRuntimeStoryletBindingsAvailable( PDO $pdo, int $gameId, array $storylet, array $runtimeState ): bool { $definitions = wayfarerRuntimeStoryletBindingDefinitions( $storylet ); foreach ($definitions as $binding) { if ( wayfarerRuntimeStoryletBindingCandidates( $pdo, $gameId, $storylet, $binding, $runtimeState ) === [] ) { return false; } } return true; } function wayfarerRuntimeResolveStoryletBindings( PDO $pdo, int $gameId, array $storylet, array $runtimeState, array $presetBindings = [] ): array { $definitions = wayfarerRuntimeStoryletBindingDefinitions( $storylet ); $resolved = []; foreach ( $definitions as $key => $binding ) { /* WAYFARER_PERSISTENT_STORYLET_BINDINGS_V88 */ if ( isset($presetBindings[$key]) && is_array($presetBindings[$key]) ) { $resolved[$key] = $presetBindings[$key]; continue; } $candidates = wayfarerRuntimeStoryletBindingCandidates( $pdo, $gameId, $storylet, $binding, $runtimeState ); if ($candidates === []) { return [ 'bindings' => [], 'error' => 'Storylet Binding "' . ( $binding['label'] ?? $key ) . '" has no eligible runtime target.', ]; } $selected = $candidates[ random_int( 0, count($candidates) - 1 ) ]; $resolved[$key] = [ 'key' => $key, 'label' => (string)( $binding['label'] ?? $key ), 'selector' => (string)( $binding['selector'] ?? '' ), 'runtime_scope' => (string)( $selected['runtime_scope'] ?? '' ), 'subject_kind' => (string)( $selected['subject_kind'] ?? 'entity' ), 'player_slot' => isset($selected['player_slot']) ? (int)$selected['player_slot'] : null, 'entity_id' => (int)( $selected['entity_id'] ?? 0 ), 'entity_type' => (string)( $selected['entity_type'] ?? '' ), 'name' => (string)$selected['name'], 'target_map' => is_array( $selected['target_map'] ?? null ) ? $selected['target_map'] : [], ]; } /* * Preset bindings that are not explicitly defined on this Storylet are * still carried for narrative interpolation. This is how a Status Outcome * can supply the reserved {{subject}} placeholder without forcing every * presentation-only Storylet to author a duplicate Binding definition. */ foreach ($presetBindings as $key => $binding) { if ( !isset($resolved[$key]) && is_array($binding) ) { $resolved[$key] = $binding; } } return [ 'bindings' => $resolved, 'error' => '', ]; } function wayfarerRuntimeMaterializeStoryletBindingEffect( array $effect, array $bindings ): array { $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); if ( preg_match( '/^binding:([a-z][a-z0-9_]{0,31})$/', $scope, $match ) !== 1 ) { return [ 'effect' => $effect, 'error' => '', ]; } $key = $match[1]; $binding = $bindings[$key] ?? null; if (!is_array($binding)) { return [ 'effect' => [], 'error' => 'Resolved Storylet Binding "' . $key . '" is missing.', ]; } $runtimeScope = strtolower( trim( (string)( $binding['runtime_scope'] ?? '' ) ) ); /* * Compatibility for V79-V84 saved runtime binding records, which stored * only entity_id. */ if ($runtimeScope === '') { $entityId = (int)( $binding['entity_id'] ?? 0 ); if ($entityId > 0) { $runtimeScope = 'entity:' . $entityId; } } if ( preg_match( '/^(?:entity:[1-9][0-9]*|player:[0-9]+)$/', $runtimeScope ) !== 1 ) { return [ 'effect' => [], 'error' => 'Resolved Storylet Binding "' . $key . '" does not point to a valid Party Member or Entity.', ]; } $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); $targetType = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); /* WAYFARER_STATUS_BINDING_RUNTIME_V87 */ if ( $targetType === 'status' && preg_match('/^[1-9][0-9]*$/', $targetId) === 1 ) { $materialized = $effect; $materialized['target_scope'] = $runtimeScope; $materialized['target_id'] = $targetId; return [ 'effect' => $materialized, 'error' => '', ]; } $targetMap = is_array( $binding['target_map'] ?? null ) ? $binding['target_map'] : []; $entityScope = strpos( $runtimeScope, 'entity:' ) === 0; $actualTargetId = $targetMap[$targetId] ?? ( $entityScope && ( $targetId === 'sys_item_quantity' || $targetId === 'sys_character_party_member' ) ? $targetId : '' ); if ($actualTargetId === '') { return [ 'effect' => [], 'error' => 'The selected subject for Binding "' . $key . '" does not expose the required Trait.', ]; } $materialized = $effect; $materialized['target_scope'] = $runtimeScope; $materialized['target_id'] = $actualTargetId; if ( $entityScope && $actualTargetId === 'sys_item_quantity' ) { $materialized['target_system_key'] = 'quantity'; } elseif ( $entityScope && $actualTargetId === 'sys_character_party_member' ) { $materialized['target_system_key'] = 'party_member'; } else { unset( $materialized['target_system_key'] ); } return [ 'effect' => $materialized, 'error' => '', ]; } /* WAYFARER_TRIGGERING_LOCATION_CONTEXT_V103 */ function wayfarerRuntimeInterpolateStoryletSourceContext(string $text, array $context): string { if ($text === '' || ($context['source_type'] ?? '') !== 'location') return $text; $name=(string)($context['location_name'] ?? ''); $text=preg_replace('/\{\{\s*location\.name\s*\}\}/i', addcslashes($name,'\\$'), $text) ?? $text; $traits=is_array($context['traits'] ?? null)?$context['traits']:[]; $text=preg_replace_callback('/\{\{\s*location\.([a-z0-9_]+)\s*\}\}/i', function(array $m) use($traits): string { $key=strtolower((string)($m[1]??'')); $trait=$traits[$key]??null; return is_array($trait)&&array_key_exists('value',$trait) ? (string)$trait['value'] : $m[0]; }, $text) ?? $text; return $text; } function wayfarerRuntimeInterpolateStoryletBindings( string $text, array $bindings ): string { if ( $text === '' || $bindings === [] ) { return $text; } $replace = function ( array $match ) use ( $bindings ): string { $key = wayfarerRuntimeStoryletBindingNormalizeKey( (string)( $match[1] ?? '' ) ); $binding = $bindings[$key] ?? null; if (!is_array($binding)) { return $match[0]; } return (string)( $binding['name'] ?? $match[0] ); }; /* * {{victim.name}} and the shorter {{victim}} both resolve to the bound * subject's current name. */ $text = preg_replace_callback( '/\{\{\s*([a-z][a-z0-9_]*)\.name\s*\}\}/i', $replace, $text ) ?? $text; $text = preg_replace_callback( '/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/i', $replace, $text ) ?? $text; return $text; } ================================================================================ FILE: play/includes/storylets.php ================================================================================ root Manual Storylet -> Storylet presentation | -> eligible child Branch -> child presentation -> ... | | Automatic/Chance firing is intentionally not handled here yet. | */ function wayfarerRuntimeLoadStorylet( PDO $pdo, int $gameId, int $storyletId ): ?array { if ($gameId <= 0 || $storyletId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT id, game_id, name, description, parent_storylet_id, branch_label, branch_sort_order, trigger_type, max_occurrences, status, config FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $storyletId, $gameId, ]); $storylet = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$storylet) { return null; } $storylet['_runtime_config'] = wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); return $storylet; } function wayfarerRuntimeStoryletOccurrenceCount( array $runtimeState, int $storyletId ): int { if ($storyletId <= 0) { return 0; } return max( 0, (int)( $runtimeState['storylets'][(string)$storyletId]['occurrences'] ?? 0 ) ); } function wayfarerRuntimeStoryletOccurrenceLimitReached( array $storylet, array $runtimeState ): bool { $maximum = $storylet['max_occurrences'] ?? null; if ( $maximum === null || $maximum === '' || !is_numeric($maximum) ) { return false; } $maximum = (int)$maximum; if ($maximum <= 0) { return false; } return wayfarerRuntimeStoryletOccurrenceCount( $runtimeState, (int)($storylet['id'] ?? 0) ) >= $maximum; } function wayfarerRuntimeStoryletConditionsPass( array $storylet, array &$runtimeState ): bool { $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); $conditions = $config['conditions'] ?? null; $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { /* WAYFARER_TRIGGERING_LOCATION_CONTEXT_V103 */ if ($scope === 'triggering_location') { $context = $runtimeState['runtime']['active_storylet_source_context'] ?? []; if (!is_array($context) || ($context['source_type'] ?? '') !== 'location') return ['found'=>false,'value'=>null]; if ($targetType === 'name' && $targetId === 'name') return ['found'=>true,'value'=>(string)($context['location_name'] ?? '')]; if ($targetType === 'trait' && $targetId !== '') { $trait=$context['traits'][$targetId] ?? null; return is_array($trait) && array_key_exists('value',$trait) ? ['found'=>true,'value'=>$trait['value']] : ['found'=>false,'value'=>null]; } } return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; return wayfarerConditionsPass( $conditions, $resolver ); } function wayfarerRuntimeStoryletIsEligible( array $storylet, array &$runtimeState ): bool { if ( strtolower( trim( (string)( $storylet['status'] ?? '' ) ) ) !== 'active' ) { return false; } if ( wayfarerRuntimeStoryletOccurrenceLimitReached( $storylet, $runtimeState ) ) { return false; } return wayfarerRuntimeStoryletConditionsPass( $storylet, $runtimeState ); } function wayfarerRuntimeStoryletPlayerPayload( array $storylet, array $runtimeState, bool $asBranch = false ): array { $name = trim( (string)( $storylet['name'] ?? '' ) ); $branchLabel = trim( (string)( $storylet['branch_label'] ?? '' ) ); $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); return [ 'id' => (int)( $storylet['id'] ?? 0 ), 'name' => $name, 'label' => $asBranch && $branchLabel !== '' ? $branchLabel : $name, 'description' => trim( (string)( $storylet['description'] ?? '' ) ), 'priority' => (int)( $config['priority'] ?? 100 ), 'occurrences' => wayfarerRuntimeStoryletOccurrenceCount( $runtimeState, (int)( $storylet['id'] ?? 0 ) ), 'max_occurrences' => $storylet['max_occurrences'] === null || $storylet['max_occurrences'] === '' ? null : (int)$storylet['max_occurrences'], ]; } function wayfarerRuntimeEligibleRootManualStorylets( PDO $pdo, int $gameId, array &$runtimeState ): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare( " SELECT id, game_id, name, description, parent_storylet_id, branch_label, branch_sort_order, trigger_type, max_occurrences, status, config FROM wf_storylets WHERE game_id = ? AND status = 'active' AND trigger_type = 'manual' AND ( parent_storylet_id IS NULL OR parent_storylet_id = 0 ) ORDER BY id " ); $stmt->execute([ $gameId ]); $actions = []; foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $storylet ) { $storylet['_runtime_config'] = wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $runtimeState ) || !wayfarerRuntimeStoryletBindingsAvailable( $pdo, $gameId, $storylet, $runtimeState ) ) { continue; } $actions[] = wayfarerRuntimeStoryletPlayerPayload( $storylet, $runtimeState, false ); } usort( $actions, function ( array $a, array $b ): int { $priority = ((int)($a['priority'] ?? 100)) <=> ((int)($b['priority'] ?? 100)); if ($priority !== 0) { return $priority; } $label = strcasecmp( (string)($a['label'] ?? ''), (string)($b['label'] ?? '') ); if ($label !== 0) { return $label; } return ((int)($a['id'] ?? 0)) <=> ((int)($b['id'] ?? 0)); } ); return $actions; } function wayfarerRuntimeEligibleStoryletBranches( PDO $pdo, int $gameId, int $parentStoryletId, array &$runtimeState ): array { if ( $gameId <= 0 || $parentStoryletId <= 0 ) { return []; } $stmt = $pdo->prepare( " SELECT id, game_id, name, description, parent_storylet_id, branch_label, branch_sort_order, trigger_type, max_occurrences, status, config FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? AND status = 'active' ORDER BY branch_sort_order, name, id " ); $stmt->execute([ $gameId, $parentStoryletId, ]); $branches = []; foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $storylet ) { $storylet['_runtime_config'] = wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $runtimeState ) || !wayfarerRuntimeStoryletBindingsAvailable( $pdo, $gameId, $storylet, $runtimeState ) ) { continue; } $payload = wayfarerRuntimeStoryletPlayerPayload( $storylet, $runtimeState, true ); $payload['branch_sort_order'] = (int)( $storylet['branch_sort_order'] ?? 0 ); $branches[] = $payload; } return $branches; } function wayfarerRuntimeEnsureStoryletTargetState( PDO $pdo, int $gameId, array &$runtimeState ): void { wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $runtimeState ); wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $runtimeState ); wayfarerRuntimeEnsureEntityState( $pdo, $gameId, $runtimeState ); wayfarerRuntimeEnsurePlayerAndGameState( $pdo, $gameId, $runtimeState ); } function wayfarerRuntimeStoryletTraitDefinitions( PDO $pdo, int $gameId, array $runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionConfig = []; if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $raw = $stmt->fetchColumn(); if ($raw !== false) { $regionConfig = wayfarerRuntimeDecodeTraitConfig( $raw ); } } return wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); } /* |-------------------------------------------------------------------------- | Storylet-to-root navigation runtime | WAYFARER_STORYLET_ROOT_CONNECTIONS_V82 |-------------------------------------------------------------------------- */ function wayfarerRuntimeConsumeStoryletTransition( array &$runtimeState ): int { $storyletId = (int)( $runtimeState['runtime']['pending_storylet_transition'] ?? 0 ); if ( isset($runtimeState['runtime']) && is_array($runtimeState['runtime']) ) { unset( $runtimeState['runtime']['pending_storylet_transition'] ); } return max( 0, $storyletId ); } function wayfarerRuntimeEnterLinkedRootStorylet( PDO $pdo, int $gameId, int $storyletId, array &$runtimeState, array $visitedStorylets = [], array $inheritedBindings = [] ): array { $chain = []; $nextStoryletId = $storyletId; while ($nextStoryletId > 0) { if (count($chain) >= 16) { throw new RuntimeException( 'Storylet Continue To chain is too deep.' ); } if ( isset( $visitedStorylets[ (string)$nextStoryletId ] ) ) { throw new RuntimeException( 'Storylet Continue To would create a circular Storylet chain.' ); } $visitedStorylets[ (string)$nextStoryletId ] = true; $linkedStorylet = wayfarerRuntimeLoadStorylet( $pdo, $gameId, $nextStoryletId ); if (!$linkedStorylet) { throw new RuntimeException( 'The linked root Storylet could not be found.' ); } if ( !empty( $linkedStorylet[ 'parent_storylet_id' ] ) ) { throw new RuntimeException( 'Storylet Continue To may target only a root Storylet.' ); } wayfarerRuntimeEnsureStoryletTargetState( $pdo, $gameId, $runtimeState ); if ( !wayfarerRuntimeStoryletIsEligible( $linkedStorylet, $runtimeState ) ) { throw new RuntimeException( 'The linked root Storylet is not currently eligible.' ); } $linkedConfig = $linkedStorylet[ '_runtime_config' ] ?? []; $tickCost = max( 0, (int)( $linkedConfig[ 'tick_cost' ] ?? 0 ) ); if ($tickCost > 0) { throw new RuntimeException( 'Storylet Tick Cost runtime is not implemented yet. Set Tick Cost to 0 for linked Storylets.' ); } /* WAYFARER_PERSISTENT_STORYLET_SUBJECT_V88 */ $linkedPresetBindings = []; if ( isset($inheritedBindings['subject']) && is_array($inheritedBindings['subject']) ) { $subjectScope = (string)( $inheritedBindings['subject']['runtime_scope'] ?? '' ); $subjectRefresh = wayfarerRuntimeStoryletPresetCharacterBinding( $pdo, $gameId, $linkedStorylet, $runtimeState, $subjectScope, 'subject', (string)( $inheritedBindings['subject']['label'] ?? 'Subject' ) ); if (($subjectRefresh['error'] ?? '') !== '') { throw new RuntimeException( (string)$subjectRefresh['error'] ); } if (is_array($subjectRefresh['binding'] ?? null)) { $linkedPresetBindings['subject'] = $subjectRefresh['binding']; } } $bindingResult = wayfarerRuntimeResolveStoryletBindings( $pdo, $gameId, $linkedStorylet, $runtimeState, $linkedPresetBindings ); if ( ($bindingResult['error'] ?? '') !== '' ) { throw new RuntimeException( (string)$bindingResult['error'] ); } $resolvedBindings = is_array( $bindingResult['bindings'] ?? null ) ? $bindingResult['bindings'] : []; /* WAYFARER_PERSISTENT_STORYLET_SUBJECT_V88 */ $inheritedBindings = isset($resolvedBindings['subject']) && is_array($resolvedBindings['subject']) ? [ 'subject' => $resolvedBindings['subject'], ] : []; $effects = wayfarerRuntimeApplyStoryletEffects( $pdo, $gameId, $linkedStorylet, $runtimeState, $resolvedBindings ); $occurrence = wayfarerRuntimeIncrementStoryletOccurrence( $runtimeState, $nextStoryletId ); $chain[] = [ 'storylet_id' => $nextStoryletId, 'bindings' => $resolvedBindings, 'effects' => $effects, 'occurrence' => $occurrence, ]; $nextTransitionId = wayfarerRuntimeConsumeStoryletTransition( $runtimeState ); if ($nextTransitionId <= 0) { return [ 'storylet' => $linkedStorylet, 'bindings' => $resolvedBindings, 'effects' => $effects, 'occurrence' => $occurrence, 'chain' => $chain, ]; } $nextStoryletId = $nextTransitionId; } throw new RuntimeException( 'Storylet Continue To did not resolve to a valid Storylet.' ); } /* * Enter a root Storylet from an internal gameplay event while already inside * the caller's locked runtime transaction. Status Outcomes use this path so * they can supply the affected character as the persistent "subject" binding. * WAYFARER_TRIGGERED_STORYLET_RUNTIME_V88 */ function wayfarerRuntimeEnterTriggeredStoryletInState( PDO $pdo, int $gameId, int $storyletId, array &$runtimeState, array $presetBindings = [], string $trigger = 'event' ): array { if ( isset($runtimeState['runtime']['presentation']) && is_array($runtimeState['runtime']['presentation']) ) { throw new RuntimeException( 'A triggered Storylet cannot open while another presentation is active.' ); } $storylet = wayfarerRuntimeLoadStorylet( $pdo, $gameId, $storyletId ); if (!$storylet) { throw new RuntimeException( 'Triggered Storylet could not be found.' ); } if (!empty($storylet['parent_storylet_id'])) { throw new RuntimeException( 'Triggered Storylets must be root Storylets.' ); } if ( strtolower(trim((string)($storylet['status'] ?? 'draft'))) !== 'active' ) { throw new RuntimeException( 'Triggered Storylet is not Active.' ); } wayfarerRuntimeEnsureStoryletTargetState( $pdo, $gameId, $runtimeState ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $runtimeState ) ) { throw new RuntimeException( 'Triggered Storylet is not currently eligible.' ); } $config = $storylet['_runtime_config'] ?? []; if ( max(0, (int)($config['tick_cost'] ?? 0)) > 0 ) { throw new RuntimeException( 'Triggered Storylet Tick Cost runtime is not implemented yet. Set Tick Cost to 0.' ); } $bindingResult = wayfarerRuntimeResolveStoryletBindings( $pdo, $gameId, $storylet, $runtimeState, $presetBindings ); if (($bindingResult['error'] ?? '') !== '') { throw new RuntimeException( (string)$bindingResult['error'] ); } $resolvedBindings = is_array($bindingResult['bindings'] ?? null) ? $bindingResult['bindings'] : []; $effects = wayfarerRuntimeApplyStoryletEffects( $pdo, $gameId, $storylet, $runtimeState, $resolvedBindings ); $occurrence = wayfarerRuntimeIncrementStoryletOccurrence( $runtimeState, $storyletId ); $presentationStorylet = $storylet; $presentationBindings = $resolvedBindings; $presentationOccurrence = $occurrence; $presentationTrigger = strtolower(trim($trigger)); $transitionId = wayfarerRuntimeConsumeStoryletTransition( $runtimeState ); if ($transitionId > 0) { $linkedResult = wayfarerRuntimeEnterLinkedRootStorylet( $pdo, $gameId, $transitionId, $runtimeState, [ (string)$storyletId => true, ], isset($resolvedBindings['subject']) && is_array($resolvedBindings['subject']) ? [ 'subject' => $resolvedBindings['subject'], ] : [] ); $presentationStorylet = $linkedResult['storylet']; $presentationBindings = $linkedResult['bindings']; $presentationOccurrence = $linkedResult['occurrence']; $presentationTrigger = 'transition'; } $presentationStoryletId = (int)($presentationStorylet['id'] ?? 0); $runtimeState['runtime']['presentation'] = [ 'type' => 'storylet', 'id' => $presentationStoryletId, 'phase' => 'presentation', 'trigger' => $presentationTrigger, 'tick' => (int)($runtimeState['runtime']['tick'] ?? 0), 'bindings' => $presentationBindings, ]; $runtimeState['runtime']['last_storylet_entry'] = [ 'storylet_id' => $presentationStoryletId, 'parent_storylet_id' => (int)($presentationStorylet['parent_storylet_id'] ?? 0), 'entry_mode' => $presentationTrigger, 'occurrence' => $presentationOccurrence, 'bindings' => $presentationBindings, 'tick' => (int)($runtimeState['runtime']['tick'] ?? 0), ]; return [ 'storylet' => $presentationStorylet, 'bindings' => $presentationBindings, 'effects' => $effects, 'occurrence' => $presentationOccurrence, 'presentation' => $runtimeState['runtime']['presentation'], ]; } function wayfarerRuntimeApplyStoryletEffects( PDO $pdo, int $gameId, array $storylet, array &$runtimeState, array $bindings = [] ): array { $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); $effects = $config['effects'] ?? []; if (!is_array($effects)) { $effects = []; } $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $traitDefinitions = wayfarerRuntimeStoryletTraitDefinitions( $pdo, $gameId, $runtimeState ); wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $regionId ); $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { throw new RuntimeException( 'Malformed Storylet Effect.' ); } $materialized = wayfarerRuntimeMaterializeStoryletBindingEffect( $effect, $bindings ); if ( ($materialized['error'] ?? '') !== '' ) { throw new RuntimeException( 'Storylet Binding Effect failed: ' . (string)$materialized['error'] ); } $runtimeEffect = $materialized['effect'] ?? $effect; if ( (string)( $runtimeEffect['target_scope'] ?? '' ) === 'storylet' && (string)( $runtimeEffect['target_type'] ?? '' ) === 'transition' ) { $selection = wayfarerRuntimeSelectEffectBranch( $runtimeState, $runtimeEffect, $regionId ); if ( ($selection['error'] ?? '') !== '' ) { throw new RuntimeException( 'Storylet Continue To Effect failed: ' . (string)$selection['error'] ); } $matched = !empty( $selection['matched'] ); $branch = is_array( $selection['branch'] ?? null ) ? $selection['branch'] : []; $operation = strtolower( trim( (string)( $branch['operation'] ?? 'none' ) ) ); $targetStoryletId = (int)( $runtimeEffect['target_id'] ?? 0 ); $applied = $matched && $operation === 'continue'; if ($applied) { if ($targetStoryletId <= 0) { throw new RuntimeException( 'Storylet Continue To Effect has an invalid destination.' ); } $existingTransition = (int)( $runtimeState['runtime']['pending_storylet_transition'] ?? 0 ); if ( $existingTransition > 0 && $existingTransition !== $targetStoryletId ) { throw new RuntimeException( 'More than one Storylet Continue To Effect matched.' ); } $runtimeState['runtime']['pending_storylet_transition'] = $targetStoryletId; } $reports[] = [ 'scope' => 'storylet', 'type' => 'transition', 'target_id' => (string)$targetStoryletId, 'operation' => $operation, 'applied' => $applied, 'matched' => $matched, 'branch_index' => $selection['branch_index'] ?? null, 'error' => '', ]; continue; } $report = wayfarerApplyRuntimeOneTimeEffect( $runtimeState, $runtimeEffect, $regionId, $traitDefinitions ); if (!empty($report['error'])) { throw new RuntimeException( 'Storylet Effect failed: ' . (string)$report['error'] ); } $reports[] = $report; /* WAYFARER_STATUS_STORYLET_ACTIVATION_V92 */ if ( strtolower(trim((string)($report['type'] ?? ''))) === 'status' && !empty($report['applied']) ) { $statusActivationReports = strtolower(trim((string)($report['scope'] ?? ''))) === 'party' && isset($report['results']) && is_array($report['results']) ? array_values($report['results']) : [$report]; foreach ($statusActivationReports as $statusActivationReport) { if ( !is_array($statusActivationReport) || empty($statusActivationReport['applied']) || (int)($statusActivationReport['before'] ?? 0) !== 0 || (int)($statusActivationReport['after'] ?? 0) !== 1 ) { continue; } $activation = wayfarerRuntimeInitializeActivatedStatus( $pdo, $gameId, $runtimeState, (string)($statusActivationReport['scope'] ?? ''), (int)($statusActivationReport['target_id'] ?? 0), $regionId, $traitDefinitions ); if (($activation['error'] ?? '') !== '') { throw new RuntimeException( 'Storylet Status activation failed: ' . (string)$activation['error'] ); } foreach ( isset($activation['entry_effects']) && is_array($activation['entry_effects']) ? $activation['entry_effects'] : [] as $entryReport ) { if (!is_array($entryReport)) { continue; } if (!empty($entryReport['error'])) { throw new RuntimeException( 'Storylet Status On Enter Effect failed: ' . (string)$entryReport['error'] ); } $entryReport['status_activation_entry_effect'] = true; $reports[] = $entryReport; } } } } /* WAYFARER_STORYLET_EFFECT_FEEDBACK_V70 */ if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_storylet_effects'] = [ 'storylet_id' => (int)( $storylet['id'] ?? 0 ), 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), 'reports' => $reports, 'changes' => wayfarerRuntimeBuildEffectChangeEvents( $pdo, $gameId, $runtimeState, $reports ), ]; return $reports; } function wayfarerRuntimeIncrementStoryletOccurrence( array &$runtimeState, int $storyletId ): int { if ( !isset($runtimeState['storylets']) || !is_array($runtimeState['storylets']) ) { $runtimeState['storylets'] = []; } $key = (string)$storyletId; if ( !isset($runtimeState['storylets'][$key]) || !is_array($runtimeState['storylets'][$key]) ) { $runtimeState['storylets'][$key] = []; } $count = wayfarerRuntimeStoryletOccurrenceCount( $runtimeState, $storyletId ) + 1; $runtimeState['storylets'][$key]['occurrences'] = $count; $runtimeState['storylets'][$key]['last_entered_tick'] = (int)( $runtimeState['runtime']['tick'] ?? 0 ); return $count; } function wayfarerRuntimeEnterStorylet( PDO $pdo, int $gameUserId, int $storyletId, string $entryMode = 'action', int $expectedParentStoryletId = 0 ): array { $entryMode = strtolower( trim($entryMode) ); if ( $entryMode !== 'action' && $entryMode !== 'branch' ) { throw new RuntimeException( 'Invalid Storylet entry mode.' ); } try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $gameId = (int)$row['game_id']; $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } if ( isset($state['runtime']['ending']) && is_array($state['runtime']['ending']) ) { throw new RuntimeException( 'This game has ended.' ); } $activePresentation = $state['runtime']['presentation'] ?? null; $storylet = wayfarerRuntimeLoadStorylet( $pdo, $gameId, $storyletId ); if (!$storylet) { throw new RuntimeException( 'Storylet not found.' ); } if ($entryMode === 'action') { if (is_array($activePresentation)) { throw new RuntimeException( 'Continue the current presentation before choosing an Action.' ); } if ( !empty($storylet['parent_storylet_id']) || strtolower( trim( (string)( $storylet['trigger_type'] ?? '' ) ) ) !== 'manual' ) { throw new RuntimeException( 'That Storylet is not an available root Manual Action.' ); } } else { if ( !is_array($activePresentation) || (string)( $activePresentation['type'] ?? '' ) !== 'storylet' || (int)( $activePresentation['id'] ?? 0 ) <= 0 ) { throw new RuntimeException( 'There is no active Storylet Branch to choose from.' ); } $parentStoryletId = (int)( $activePresentation['id'] ?? 0 ); if ( $expectedParentStoryletId > 0 && $parentStoryletId !== $expectedParentStoryletId ) { throw new RuntimeException( 'The active Storylet changed before the Branch was selected.' ); } if ( (int)( $storylet['parent_storylet_id'] ?? 0 ) !== $parentStoryletId ) { throw new RuntimeException( 'That Storylet is not a Branch of the active Storylet.' ); } } wayfarerRuntimeEnsureStoryletTargetState( $pdo, $gameId, $state ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $state ) || ( $entryMode === 'action' && !wayfarerRuntimeStoryletBindingsAvailable( $pdo, $gameId, $storylet, $state ) ) ) { throw new RuntimeException( 'That Storylet is no longer eligible.' ); } $config = $storylet['_runtime_config'] ?? []; $tickCost = max( 0, (int)( $config['tick_cost'] ?? 0 ) ); /* * V59 proves interactive Storylets before refactoring the normal tick * pipeline into a reusable "consume N ticks" helper. Reject non-zero * cost rather than silently ignoring authored time passage. */ if ($tickCost > 0) { throw new RuntimeException( 'Storylet Tick Cost runtime is not implemented yet. Set Tick Cost to 0 for V59.' ); } /* WAYFARER_STORYLET_BINDING_RUNTIME_V79 */ /* WAYFARER_PERSISTENT_STORYLET_SUBJECT_V88 */ $presetBindings = []; if ( $entryMode === 'branch' && isset($activePresentation['bindings']['subject']) && is_array($activePresentation['bindings']['subject']) ) { $subjectScope = (string)( $activePresentation['bindings']['subject']['runtime_scope'] ?? '' ); $subjectRefresh = wayfarerRuntimeStoryletPresetCharacterBinding( $pdo, $gameId, $storylet, $state, $subjectScope, 'subject', (string)( $activePresentation['bindings']['subject']['label'] ?? 'Subject' ) ); if (($subjectRefresh['error'] ?? '') !== '') { throw new RuntimeException( (string)$subjectRefresh['error'] ); } if (is_array($subjectRefresh['binding'] ?? null)) { $presetBindings['subject'] = $subjectRefresh['binding']; } } $bindingResult = wayfarerRuntimeResolveStoryletBindings( $pdo, $gameId, $storylet, $state, $presetBindings ); if ( ($bindingResult['error'] ?? '') !== '' ) { throw new RuntimeException( (string)$bindingResult['error'] ); } $resolvedBindings = is_array( $bindingResult['bindings'] ?? null ) ? $bindingResult['bindings'] : []; $effects = wayfarerRuntimeApplyStoryletEffects( $pdo, $gameId, $storylet, $state, $resolvedBindings ); $occurrence = wayfarerRuntimeIncrementStoryletOccurrence( $state, $storyletId ); if ($entryMode === 'branch') { $state['runtime']['last_presentation'] = $activePresentation; } $presentationStorylet = $storylet; $presentationBindings = $resolvedBindings; $presentationOccurrence = $occurrence; $presentationTrigger = $entryMode; $transitionId = wayfarerRuntimeConsumeStoryletTransition( $state ); if ($transitionId > 0) { $linkedResult = wayfarerRuntimeEnterLinkedRootStorylet( $pdo, $gameId, $transitionId, $state, [ (string)$storyletId => true, ], isset($resolvedBindings['subject']) && is_array($resolvedBindings['subject']) ? [ 'subject' => $resolvedBindings['subject'], ] : [] ); $presentationStorylet = $linkedResult['storylet']; $presentationBindings = $linkedResult['bindings']; $presentationOccurrence = $linkedResult['occurrence']; $presentationTrigger = 'transition'; $state['runtime']['last_storylet_transition'] = [ 'from_storylet_id' => $storyletId, 'to_storylet_id' => (int)( $presentationStorylet['id'] ?? 0 ), 'chain' => $linkedResult['chain'], 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } else { unset( $state['runtime']['last_storylet_transition'] ); } $presentationStoryletId = (int)( $presentationStorylet['id'] ?? 0 ); $state['runtime']['presentation'] = [ 'type' => 'storylet', 'id' => $presentationStoryletId, 'phase' => 'presentation', 'trigger' => $presentationTrigger, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), 'bindings' => $presentationBindings, ]; /* WAYFARER_LOCATION_STORYLET_TRANSITIONS_V102 */ if ( $entryMode === 'branch' && is_array($activePresentation) && isset($activePresentation['source_context']) && is_array($activePresentation['source_context']) ) { $state['runtime']['presentation']['source_context'] = $activePresentation['source_context']; if (isset($activePresentation['location_trigger'])) { $state['runtime']['presentation']['location_trigger'] = (string)$activePresentation['location_trigger']; } } $state['runtime']['last_storylet_entry'] = [ 'storylet_id' => $presentationStoryletId, 'parent_storylet_id' => (int)( $presentationStorylet['parent_storylet_id'] ?? 0 ), 'entry_mode' => $presentationTrigger, 'occurrence' => $presentationOccurrence, 'bindings' => $presentationBindings, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; $ending = wayfarerRuntimeEvaluateGameEndings( $pdo, $gameId, $state ); $encoded = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encoded, $gameUserId, ]); $pdo->commit(); $presentation = $state['runtime']['presentation'] ?? null; return [ 'game_user_id' => (int)$row['id'], 'game_id' => $gameId, 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, $gameId, $state ), 'presentation' => is_array($presentation) ? $presentation : null, 'ending' => $ending, 'effects' => $effects, 'occurrence' => $presentationOccurrence, 'actions' => $ending === null && !is_array($presentation) ? wayfarerRuntimeEligibleRootManualStorylets( $pdo, $gameId, $state ) : [], ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: play/includes/tick-panel.php ================================================================================ getMessage(); } ?>

Tick Runtime Test

Persisted tick:

Runtime status: Paused

================================================================================ FILE: play/index.php ================================================================================ prepare(" SELECT g.id AS game_id, g.name AS game_name, g.slug AS game_slug, g.instructions AS game_instructions, g.player_slots AS player_slots, g.config AS game_config, gu.id AS game_user_id, gu.role AS game_user_role, gu.state, u.username, u.display_name FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id INNER JOIN wf_users u ON u.id = gu.user_id WHERE gu.game_id = ? AND gu.user_id = ? LIMIT 1 "); $stmt->execute([ $gameId, $userId ]); $gameUser = $stmt->fetch(); if (!$gameUser) { die('Game user not found.'); } /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * Author permission belongs to the user/game relationship, not to the user * globally. wf_game_users.role is therefore the canonical source. */ $canUseAuthorMode = strtolower( trim( (string)( $gameUser['game_user_role'] ?? '' ) ) ) === 'author'; // ================================================================ // RUNTIME STATE // ================================================================ $state = null; if (!empty($gameUser['state'])) { $decodedState = json_decode( $gameUser['state'], true ); if (is_array($decodedState)) { $state = $decodedState; } } /* * WAYFARER_ENTITY_RUNTIME_STATE_PAGE_RECOVERY_V49 * * Existing development playthroughs should gain Entity state immediately on * page load. This is additive and does not overwrite current runtime values. */ if ($state !== null) { $runtimeRecoveryChanged = wayfarerRuntimeEnsureEntityState( $pdo, (int)$gameId, $state ); /* WAYFARER_PLAYER_TRAITS_PAGE_RECOVERY_V52 */ if ( wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$gameId, $state ) ) { $runtimeRecoveryChanged = true; } if ($runtimeRecoveryChanged) { $encodedRecoveredState = wayfarerRuntimeEncodeState( $state ); $entityRecoveryStmt = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $entityRecoveryStmt->execute([ $encodedRecoveredState, (int)$gameUser['game_user_id'], ]); /* * Keep Author Debug and any later page component on the same snapshot. */ $gameUser['state'] = $encodedRecoveredState; } } // ================================================================ // INITIALIZE GAME // ================================================================ $initializationError = ''; if ( $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'initialize' && $state === null ) { try { wayfarerRuntimeInitializeGame( $pdo, (int)$gameUser['game_user_id'], [ 'player_names' => $_POST['player_names'] ?? [], 'choices' => $_POST['setup_choices'] ?? [], ] ); /* * Reload from persisted state so every play-page component sees the * exact same initialized snapshot. */ header('Location: index.php'); exit; } catch (Throwable $exception) { $initializationError = $exception->getMessage(); } } // ================================================================ // AUTHOR MODE: GAME TRAIT DEFINITIONS // ================================================================ $gameTraitDefinitions = []; $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } foreach ($gameConfig['traits'] ?? [] as $trait) { if (empty($trait['id'])) { continue; } $gameTraitDefinitions[$trait['id']] = $trait; } ?> <?= htmlspecialchars($gameUser['game_name']) ?>

Player:



================================================================================ FILE: populate-wayfarer-builder-components.php ================================================================================ \n"; } function v103_fail(string $m): void { v103_out(''); v103_out('Update V103 failed.'); v103_out($m); exit; } function v103_write(string $p,string $c): void { $t=$p.'.v103.tmp'; if(file_put_contents($t,$c)===false) throw new RuntimeException('Could not write '.$t); if(!rename($t,$p)){@unlink($t); throw new RuntimeException('Could not replace '.$p);} } function v103_replace(string $c,string $o,string $n,string $label): string { $k=substr_count($c,$o); if($k!==1) throw new RuntimeException('Preflight failed for '.$label.'. Expected marker exactly once; found '.$k.'.'); return str_replace($o,$n,$c); } function v103_slug(string $s): string { $s=strtolower(trim($s)); $s=preg_replace('/[^a-z0-9]+/','_',$s)??''; return trim($s,'_'); } $root=__DIR__; $paths=[ 'gameplay'=>$root.'/includes/gameplay/functions.php', 'builder'=>$root.'/includes/builder/storylet-gameplay.php', 'runtime'=>$root.'/play/includes/runtime.php', 'storylets'=>$root.'/play/includes/storylets.php', 'bindings'=>$root.'/play/includes/storylet-bindings.php', 'presentation'=>$root.'/play/includes/presentation-screen.php', 'tokens'=>$root.'/assets/js/storylet-tokens.js', ]; foreach($paths as $p){ if(!is_file($p)) v103_fail('Required file was not found: '.str_replace($root.'/','',$p).'. No files were changed.'); } $src=[]; foreach($paths as $k=>$p){$src[$k]=file_get_contents($p); if($src[$k]===false)v103_fail('Could not read '.$p);} if(strpos($src['runtime'],'WAYFARER_LOCATION_STORYLET_TRANSITIONS_V102')===false) v103_fail('V102 was not detected. Install V102 before V103. No files were changed.'); if(strpos($src['gameplay'],'WAYFARER_TRIGGERING_LOCATION_CONTEXT_V103')!==false){v103_out('Wayfarer Update V103');v103_out('');v103_out('V103 is already installed. No files were changed.');exit;} try { $backup=$root.'/_update_backups/v103-'.date('Ymd-His'); if(!is_dir($backup)&&!mkdir($backup,0775,true)) throw new RuntimeException('Could not create backup directory.'); foreach($paths as $k=>$p){$rel=str_replace($root.'/','',$p);$bp=$backup.'/'.$rel;if(!is_dir(dirname($bp))&&!mkdir(dirname($bp),0775,true))throw new RuntimeException('Could not create backup path.');if(!copy($p,$bp))throw new RuntimeException('Could not back up '.$rel);} /* Builder catalog helper: generic Triggering Location -> Name/Trait condition targets. */ $anchor="function gameplayRuntimeStateTargets(\n"; $helper=<<<'PHP2' /* |-------------------------------------------------------------------------- | Triggering Location Storylet Context | WAYFARER_TRIGGERING_LOCATION_CONTEXT_V103 |-------------------------------------------------------------------------- */ function gameplayTriggeringLocationContextTargets(PDO $pdo, int $gameId): array { $targets = [ 'triggering_location:name:name' => [ 'scope' => 'triggering_location', 'target_scope' => 'triggering_location', 'target_type' => 'name', 'target_id' => 'name', 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'triggering_location_name', 'condition_operators' => ['equals','not_equals'], 'effect_hidden' => true, 'supports_random' => false, 'group' => 'Runtime Context', 'label' => 'Triggering Location → Name', 'hierarchy' => [ ['key'=>'runtime_context','label'=>'Runtime Context'], ['key'=>'triggering_location','label'=>'Triggering Location'], ['key'=>'name','label'=>'Name'], ], ], ]; if ($gameId <= 0) return $targets; $stmt=$pdo->prepare("SELECT config FROM wf_locations WHERE game_id = ? ORDER BY id"); $stmt->execute([$gameId]); $seen=[]; foreach($stmt->fetchAll(PDO::FETCH_ASSOC) as $row){ $cfg=gameplayRuntimeStateDecodeConfig($row['config']??null); foreach(($cfg['traits']??[]) as $trait){ if(!is_array($trait) || (array_key_exists('active',$trait)&&!$trait['active'])) continue; $name=trim((string)($trait['name']??$trait['label']??'')); if($name==='') continue; $slug=strtolower(trim((string)preg_replace('/[^a-z0-9]+/i','_',$name),'_')); if($slug===''||isset($seen[$slug])) continue; $seen[$slug]=true; $type=strtolower(trim((string)($trait['type']??$trait['data_type']??'text'))); if($type==='binary')$type='boolean'; if(!in_array($type,['number','boolean','text'],true))$type='text'; $key='triggering_location:trait:'.$slug; $targets[$key]=[ 'scope'=>'triggering_location','target_scope'=>'triggering_location','target_type'=>'trait','target_id'=>$slug, 'trait_type'=>$type,'data_type'=>$type,'semantic_type'=>'triggering_location_trait','effect_hidden'=>true,'supports_random'=>false, 'condition_operators'=>$type==='number'?wayfarerConditionOperators():['equals','not_equals'], 'group'=>'Runtime Context','label'=>'Triggering Location → '.$name, 'hierarchy'=>[ ['key'=>'runtime_context','label'=>'Runtime Context'], ['key'=>'triggering_location','label'=>'Triggering Location'], ['key'=>'trait:'.$slug,'label'=>$name], ], ]; } } return $targets; } PHP2; $src['gameplay']=v103_replace($src['gameplay'],$anchor,$helper.$anchor,'gameplay target helper anchor'); $old="\$storyletConditionTargets =\n gameplayRuntimeStateTargets(\n \$pdo,\n \$gameId\n );"; $new="\$storyletConditionTargets =\n array_replace(\n gameplayRuntimeStateTargets(\n \$pdo,\n \$gameId\n ),\n gameplayTriggeringLocationContextTargets(\n \$pdo,\n \$gameId\n )\n );\n\n/* WAYFARER_TRIGGERING_LOCATION_CONTEXT_V103 */\n\$storyletTriggeringLocationTraitTokens = [];\nforeach (gameplayTriggeringLocationContextTargets(\$pdo, \$gameId) as \$target) {\n if (!is_array(\$target) || (\$target['target_type'] ?? '') !== 'trait') continue;\n \$storyletTriggeringLocationTraitTokens[] = [\n 'label' => (string)(end(\$target['hierarchy'])['label'] ?? \$target['target_id']),\n 'token' => '{{location.' . (string)\$target['target_id'] . '}}',\n ];\n}\necho '';"; $src['builder']=v103_replace($src['builder'],$old,$new,'Storylet condition target catalog'); /* Runtime: enrich V102 source context with a location name and snapshot of authored trait values. */ $old=" unset(\n \$runtimeState['runtime']['pending_storylet_source_context']\n );\n\n wayfarerRuntimeEnterTriggeredStoryletInState("; $new=<<<'PHP2' /* WAYFARER_TRIGGERING_LOCATION_CONTEXT_V103 */ $locationConfig = isset($location['_runtime_config']) && is_array($location['_runtime_config']) ? $location['_runtime_config'] : wayfarerRuntimeDecodeConfig($location['config'] ?? null); $sourceContext['location_name'] = trim((string)($location['name'] ?? '')); $sourceContext['traits'] = []; foreach (($locationConfig['traits'] ?? []) as $trait) { if (!is_array($trait) || (array_key_exists('active',$trait) && !$trait['active'])) continue; $traitName=trim((string)($trait['name'] ?? $trait['label'] ?? '')); if($traitName==='') continue; $slug=strtolower(trim((string)preg_replace('/[^a-z0-9]+/i','_',$traitName),'_')); if($slug==='') continue; $sourceContext['traits'][$slug] = [ 'name'=>$traitName, 'trait_id'=>(string)($trait['id'] ?? $trait['system_key'] ?? ''), 'value'=>array_key_exists('initial',$trait) ? $trait['initial'] : ($trait['value'] ?? null), ]; } $runtimeState['runtime']['active_storylet_source_context'] = $sourceContext; unset( $runtimeState['runtime']['pending_storylet_source_context'] ); wayfarerRuntimeEnterTriggeredStoryletInState( PHP2; $src['runtime']=v103_replace($src['runtime'],$old,$new,'V102 Location promotion context'); /* Storylet condition resolver: generic triggering_location reads the active snapshot. */ $old=" ): array {\n\n return\n wayfarerRuntimeResolveConditionTarget("; $new=<<<'PHP2' ): array { /* WAYFARER_TRIGGERING_LOCATION_CONTEXT_V103 */ if ($scope === 'triggering_location') { $context = $runtimeState['runtime']['active_storylet_source_context'] ?? []; if (!is_array($context) || ($context['source_type'] ?? '') !== 'location') return ['found'=>false,'value'=>null]; if ($targetType === 'name' && $targetId === 'name') return ['found'=>true,'value'=>(string)($context['location_name'] ?? '')]; if ($targetType === 'trait' && $targetId !== '') { $trait=$context['traits'][$targetId] ?? null; return is_array($trait) && array_key_exists('value',$trait) ? ['found'=>true,'value'=>$trait['value']] : ['found'=>false,'value'=>null]; } } return wayfarerRuntimeResolveConditionTarget( PHP2; $src['storylets']=v103_replace($src['storylets'],$old,$new,'Storylet condition resolver'); /* Narrative interpolation helper. */ $anchor="function wayfarerRuntimeInterpolateStoryletBindings(\n"; $helper=<<<'PHP2' /* WAYFARER_TRIGGERING_LOCATION_CONTEXT_V103 */ function wayfarerRuntimeInterpolateStoryletSourceContext(string $text, array $context): string { if ($text === '' || ($context['source_type'] ?? '') !== 'location') return $text; $name=(string)($context['location_name'] ?? ''); $text=preg_replace('/\{\{\s*location\.name\s*\}\}/i', addcslashes($name,'\\$'), $text) ?? $text; $traits=is_array($context['traits'] ?? null)?$context['traits']:[]; $text=preg_replace_callback('/\{\{\s*location\.([a-z0-9_]+)\s*\}\}/i', function(array $m) use($traits): string { $key=strtolower((string)($m[1]??'')); $trait=$traits[$key]??null; return is_array($trait)&&array_key_exists('value',$trait) ? (string)$trait['value'] : $m[0]; }, $text) ?? $text; return $text; } PHP2; $src['bindings']=v103_replace($src['bindings'],$anchor,$helper.$anchor,'Storylet interpolation helper anchor'); $old=" \$presentationDescription =\n wayfarerRuntimeInterpolateStoryletBindings(\n \$presentationDescription,\n \$presentation['bindings']\n );\n}"; $new=$old."\n\n/* WAYFARER_TRIGGERING_LOCATION_CONTEXT_V103 */\nif (\$presentationType === 'storylet') {\n \$sourceContext = isset(\$presentation['source_context']) && is_array(\$presentation['source_context'])\n ? \$presentation['source_context']\n : (\$state['runtime']['active_storylet_source_context'] ?? []);\n if (is_array(\$sourceContext)) {\n \$presentationName = wayfarerRuntimeInterpolateStoryletSourceContext(\$presentationName, \$sourceContext);\n \$presentationDescription = wayfarerRuntimeInterpolateStoryletSourceContext(\$presentationDescription, \$sourceContext);\n }\n}"; $src['presentation']=v103_replace($src['presentation'],$old,$new,'presentation source-context interpolation'); /* Token picker: add Triggering Location group dynamically. */ $old=" const bindingOptions = picker.querySelector('[data-storylet-binding-token-options]');\n\n if (!toggle || !menu || !bindingOptions) {"; $new=" const bindingOptions = picker.querySelector('[data-storylet-binding-token-options]');\n\n /* WAYFARER_TRIGGERING_LOCATION_CONTEXT_V103 */\n const runtimeGroup = menu.querySelector('.wayfarer-storylet-token-group');\n if (runtimeGroup && !runtimeGroup.querySelector('[data-storylet-location-token-group]')) {\n const group = document.createElement('div');\n group.className = 'wayfarer-storylet-token-group';\n group.dataset.storyletLocationTokenGroup = '1';\n const heading=document.createElement('strong'); heading.textContent='Triggering Location'; group.appendChild(heading);\n const help=document.createElement('small'); help.textContent='Values snapshotted from the Location that opened this Storylet.'; group.appendChild(help);\n const rows=[{label:'Location Name',token:'{{location.name}}'}].concat(Array.isArray(window.wayfarerTriggeringLocationTraitTokens)?window.wayfarerTriggeringLocationTraitTokens:[]);\n rows.forEach(function(row){ const b=document.createElement('button'); b.type='button'; b.className='wayfarer-storylet-token-option'; b.dataset.storyletToken=String(row.token||''); const s=document.createElement('span');s.textContent=String(row.label||'Location Value');const c=document.createElement('code');c.textContent=String(row.token||'');b.appendChild(s);b.appendChild(c);group.appendChild(b);});\n runtimeGroup.insertAdjacentElement('afterend',group);\n }\n\n if (!toggle || !menu || !bindingOptions) {"; $src['tokens']=v103_replace($src['tokens'],$old,$new,'Storylet token picker context group'); foreach($paths as $k=>$p) v103_write($p,$src[$k]); v103_out('Wayfarer Update V103'); v103_out('Triggering Location Storylet Context'); v103_out(''); v103_out('V103 installed successfully.'); v103_out(''); v103_out('Storylet Conditions can use Runtime Context → Triggering Location → Name / Trait.'); v103_out('Storylet text can use {{location.name}} and {{location.trait_name}} tokens.'); v103_out('Location Trait values are snapshotted when the Location launches the Storylet.'); v103_out('Backup: '.str_replace($root.'/','',$backup)); } catch(Throwable $e){ v103_fail($e->getMessage().' No additional files were changed after the failure point; restore from the V103 backup if needed.'); } ================================================================================ FILE: runtim.php ================================================================================ isFile()) { continue; } if (strtolower($file->getExtension()) !== 'php') { continue; } $absolute = $file->getPathname(); // Convert to project-relative path. $relative = str_replace('\\', '/', substr($absolute, strlen($root) + 1)); $discovered[$relative] = true; } } catch (Throwable $e) { // We'll report directory scan problems later. } } foreach (array_keys($discovered) as $relative) { if (!in_array($relative, $relativeFiles, true)) { $relativeFiles[] = $relative; } } sort($relativeFiles); // ------------------------------------------------------------ // Helpers // ------------------------------------------------------------ function section($title) { return "\n\n" . str_repeat('=', 78) . "\n" . $title . "\n" . str_repeat('=', 78) . "\n\n"; } function subsection($title) { return "\n" . str_repeat('-', 78) . "\n" . $title . "\n" . str_repeat('-', 78) . "\n\n"; } function safeFileContents($file) { if (!is_file($file)) { return "[FILE NOT FOUND]\n"; } $contents = @file_get_contents($file); if ($contents === false) { return "[COULD NOT READ FILE]\n"; } return $contents; } function sanitizeDatabaseConfig($contents) { /* * We do NOT need database credentials. * * Redact common password assignments while retaining enough of * database.php to understand how the connection is established. */ $patterns = [ '/(\$password\s*=\s*)([\'"]).*?\2\s*;/i', '/(\$pass\s*=\s*)([\'"]).*?\2\s*;/i', '/(\$dbPass\s*=\s*)([\'"]).*?\2\s*;/i', '/(\$dbPassword\s*=\s*)([\'"]).*?\2\s*;/i', '/([\'"]password[\'"]\s*=>\s*)([\'"]).*?\2/i', '/([\'"]pass[\'"]\s*=>\s*)([\'"]).*?\2/i', ]; foreach ($patterns as $pattern) { $contents = preg_replace( $pattern, '$1\'[REDACTED]\'', $contents ); } return $contents; } // ------------------------------------------------------------ // Start report // ------------------------------------------------------------ $out = ''; $out .= section('WAYFARER HIERARCHY / CONDITIONS / EFFECTS REVIEW'); $out .= "Generated: " . date('Y-m-d H:i:s') . "\n"; $out .= "PHP Version: " . PHP_VERSION . "\n"; $out .= "Project Root: " . $root . "\n"; $out .= "\nPURPOSE\n"; $out .= "Current-state review before rebuilding Wayfarer Conditions and Effects\n"; $out .= "into hierarchical, searchable target selectors.\n"; $out .= "\nPLANNED ARCHITECTURE\n"; $out .= "Target Type -> Specific Target -> Property\n"; $out .= "with contextual search at each applicable hierarchy level.\n"; $out .= "\nFUTURE TARGET TYPES TO ACCOMMODATE\n"; $out .= "- Storylet navigation\n"; $out .= "- Status\n"; $out .= "- Storylet Subject\n"; $out .= "- Calendar / Time / Season\n"; // ------------------------------------------------------------ // File inventory // ------------------------------------------------------------ $out .= section('PHP FILE INVENTORY'); foreach ($relativeFiles as $relative) { $absolute = $root . '/' . $relative; if (is_file($absolute)) { $out .= sprintf( "%-65s %10d bytes\n", $relative, filesize($absolute) ); } else { $out .= sprintf( "%-65s %s\n", $relative, '[NOT FOUND]' ); } } // ------------------------------------------------------------ // Full PHP source // ------------------------------------------------------------ $out .= section('CURRENT PHP SOURCE'); foreach ($relativeFiles as $relative) { $absolute = $root . '/' . $relative; $out .= subsection('FILE: ' . $relative); $contents = safeFileContents($absolute); if (basename($relative) === 'database.php') { $contents = sanitizeDatabaseConfig($contents); } $out .= $contents; $out .= "\n"; } // ------------------------------------------------------------ // Database schema // ------------------------------------------------------------ $out .= section('DATABASE SCHEMA'); if (!$dbLoaded) { $out .= "[DATABASE CONFIGURATION COULD NOT BE LOCATED]\n"; } elseif (!isset($pdo) || !($pdo instanceof PDO)) { $out .= "[PDO CONNECTION NOT AVAILABLE AFTER LOADING CONFIG]\n"; } else { try { $stmt = $pdo->query("SHOW TABLES"); $tables = $stmt->fetchAll(PDO::FETCH_COLUMN); foreach ($tables as $table) { // Only export Wayfarer tables. if (strpos($table, 'wf_') !== 0) { continue; } $out .= subsection('TABLE: ' . $table); // CREATE TABLE gives us indexes, datatypes, defaults, etc. $quotedTable = '`' . str_replace('`', '``', $table) . '`'; try { $createStmt = $pdo->query( "SHOW CREATE TABLE " . $quotedTable ); $createRow = $createStmt->fetch(PDO::FETCH_ASSOC); if ($createRow) { $createSql = ''; foreach ($createRow as $key => $value) { if ( stripos($key, 'Create Table') !== false || stripos($key, 'Create View') !== false ) { $createSql = $value; break; } } if ($createSql !== '') { $out .= $createSql . ";\n\n"; } } } catch (Throwable $e) { $out .= "[SHOW CREATE TABLE FAILED: " . $e->getMessage() . "]\n\n"; } // Column detail try { $descStmt = $pdo->query( "DESCRIBE " . $quotedTable ); $columns = $descStmt->fetchAll(PDO::FETCH_ASSOC); $out .= "COLUMNS:\n"; foreach ($columns as $column) { $out .= json_encode( $column, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) . "\n"; } $out .= "\n"; } catch (Throwable $e) { $out .= "[DESCRIBE FAILED: " . $e->getMessage() . "]\n\n"; } } } catch (Throwable $e) { $out .= "[DATABASE SCHEMA EXPORT FAILED]\n"; $out .= $e->getMessage() . "\n"; } } // ------------------------------------------------------------ // Relevant database records // ------------------------------------------------------------ // // Export structural authoring data for game 1 where practical. // This helps identify the actual JSON shapes currently being saved. // // We deliberately avoid wf_users and other account/authentication // data. // ------------------------------------------------------------ $out .= section('GAME 1 AUTHORING DATA / CURRENT JSON SHAPES'); if (isset($pdo) && $pdo instanceof PDO) { $candidateTables = [ 'wf_games', 'wf_regions', 'wf_locations', 'wf_entities', 'wf_storylets', 'wf_statuses', ]; foreach ($candidateTables as $table) { try { $existsStmt = $pdo->prepare(" SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ? "); $existsStmt->execute([$table]); if (!$existsStmt->fetchColumn()) { continue; } $out .= subsection('DATA: ' . $table); // Determine whether this table has game_id. $columnStmt = $pdo->prepare(" SELECT COLUMN_NAME FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? "); $columnStmt->execute([$table]); $columns = $columnStmt->fetchAll(PDO::FETCH_COLUMN); $quotedTable = '`' . str_replace('`', '``', $table) . '`'; if ($table === 'wf_games') { $dataStmt = $pdo->query( "SELECT * FROM " . $quotedTable . " WHERE id = 1" ); } elseif (in_array('game_id', $columns, true)) { $dataStmt = $pdo->query( "SELECT * FROM " . $quotedTable . " WHERE game_id = 1" ); } else { $out .= "[No game_id column; structural schema captured above.]\n"; continue; } while ($row = $dataStmt->fetch(PDO::FETCH_ASSOC)) { /* * Avoid accidentally exporting obviously sensitive * authentication-like fields if one appears somewhere * unexpected. */ foreach ($row as $key => &$value) { if ( preg_match( '/password|passwd|secret|token|api[_-]?key/i', $key ) ) { $value = '[REDACTED]'; } } unset($value); $out .= json_encode( $row, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $out .= "\n\n"; } } catch (Throwable $e) { $out .= subsection('DATA ERROR: ' . $table); $out .= $e->getMessage() . "\n"; } } } else { $out .= "[DATABASE CONNECTION UNAVAILABLE]\n"; } // ------------------------------------------------------------ // Search markers // ------------------------------------------------------------ $out .= section('CONDITIONS / EFFECTS / TARGET MARKER INDEX'); $searchTerms = [ 'condition', 'conditions', 'effect', 'effects', 'target', 'trait', 'storylet', 'branch', 'random', 'min', 'max', ]; foreach ($relativeFiles as $relative) { $absolute = $root . '/' . $relative; if (!is_file($absolute)) { continue; } $lines = @file($absolute); if ($lines === false) { continue; } $matches = []; foreach ($lines as $number => $line) { foreach ($searchTerms as $term) { if (stripos($line, $term) !== false) { $matches[$number] = rtrim($line); break; } } } if (!$matches) { continue; } $out .= subsection('MARKERS: ' . $relative); foreach ($matches as $number => $line) { $out .= sprintf( "%6d | %s\n", $number + 1, $line ); } } // ------------------------------------------------------------ // Finish // ------------------------------------------------------------ $out .= section('END OF WAYFARER REVIEW'); $out .= "This diagnostic file may now be deleted from the server.\n"; // ------------------------------------------------------------ // Download // ------------------------------------------------------------ $filename = 'wayfarer-hierarchy-review-' . date('Ymd-His') . '.txt'; header('Content-Type: text/plain; charset=utf-8'); header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); header('Content-Length: ' . strlen($out)); header('Cache-Control: no-store, no-cache, must-revalidate'); header('Pragma: no-cache'); echo $out; exit; ================================================================================ FILE: testgame.php ================================================================================ 100, 'tick_cost' => 0, 'chance_percent' => 0, 'interrupts_travel' => '0', ], $extra ); } function test_fixed_effect( string $scope, string $targetType, string $targetId, string $operation, $value, string $systemKey = '' ): array { $effect = [ 'target_scope' => $scope, 'target_type' => $targetType, 'target_id' => $targetId, 'operation' => $operation, 'branches' => [ [ 'conditions' => null, 'operation' => $operation, 'value_mode' => 'fixed', 'value' => $value, ], ], 'value_mode' => 'fixed', 'value' => $value, ]; if ($systemKey !== '') { $effect['target_system_key'] = $systemKey; } return $effect; } function test_random_effect( string $scope, string $targetType, string $targetId, string $operation, $minimum, $maximum, string $systemKey = '' ): array { $effect = [ 'target_scope' => $scope, 'target_type' => $targetType, 'target_id' => $targetId, 'operation' => $operation, 'branches' => [ [ 'conditions' => null, 'operation' => $operation, 'value_mode' => 'random', 'value_min' => $minimum, 'value_max' => $maximum, ], ], 'value_mode' => 'random', 'value_min' => $minimum, 'value_max' => $maximum, ]; if ($systemKey !== '') { $effect['target_system_key'] = $systemKey; } return $effect; } function test_continue_effect(int $storyletId): array { return [ 'target_scope' => 'storylet', 'target_type' => 'transition', 'target_id' => (string)$storyletId, 'operation' => 'continue', 'branches' => [ [ 'conditions' => null, 'operation' => 'continue', 'value_mode' => 'fixed', 'value' => '', ], ], 'value_mode' => 'fixed', 'value' => '', ]; } function test_condition( string $scope, string $targetType, string $targetId, string $operator, $value ): array { return [ 'type' => 'condition', 'scope' => $scope, 'target_type' => $targetType, 'target_id' => $targetId, 'operator' => $operator, 'value' => $value, ]; } function test_find_entity( PDO $pdo, int $gameId, string $type, string $name ): int { $stmt = $pdo->prepare( " SELECT id FROM wf_entities WHERE game_id = ? AND LOWER(entity_type) = LOWER(?) AND name = ? AND status = 'active' LIMIT 1 " ); $stmt->execute([ $gameId, $type, $name, ]); $id = (int)$stmt->fetchColumn(); if ($id <= 0) { test_fail( 'Required active ' . $type . ' not found: ' . $name ); } return $id; } function test_insert_storylet( PDO $pdo, int $gameId, string $name, string $key, string $description, array $config, ?int $parentId = null, string $branchLabel = '', int $branchSortOrder = 0 ): int { $stmt = $pdo->prepare( " INSERT INTO wf_storylets ( uuid, game_id, name, storylet_key, description, conditions, effects, max_occurrences, trigger_type, status, config, parent_storylet_id, choice_label, choice_sort_order, branch_label, branch_sort_order ) VALUES ( ?, ?, ?, ?, ?, NULL, NULL, NULL, 'manual', 'active', ?, ?, NULL, 0, ?, ? ) " ); $stmt->execute([ test_uuid(), $gameId, $name, $key, $description, test_json($config), $parentId, $branchLabel, $branchSortOrder, ]); return (int)$pdo->lastInsertId(); } /* |-------------------------------------------------------------------------- | Preflight |-------------------------------------------------------------------------- */ $databasePath = $root . '/config/database.php'; if (!is_file($databasePath)) { test_fail( 'Could not find config/database.php. ' . 'Upload this seeder to the Wayfarer project root.' ); } $functionsPath = $root . '/includes/gameplay/functions.php'; if (!is_file($functionsPath)) { test_fail( 'Could not find includes/gameplay/functions.php.' ); } $functionsSource = file_get_contents($functionsPath); if ( $functionsSource === false || strpos( $functionsSource, 'WAYFARER_STORYLET_ROOT_CONNECTIONS_V82' ) === false ) { test_fail( 'Wayfarer V82 was not detected. ' . 'Install V82 before seeding these Storylet navigation tests.' ); } require $databasePath; if ( !isset($pdo) || !($pdo instanceof PDO) ) { test_fail( 'config/database.php did not provide the expected $pdo connection.' ); } $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); /* |-------------------------------------------------------------------------- | Resolve Stormgate + existing entities |-------------------------------------------------------------------------- */ $gameStmt = $pdo->prepare( " SELECT id, name, slug, config FROM wf_games WHERE slug = 'stormgate' LIMIT 1 " ); $gameStmt->execute(); $game = $gameStmt->fetch( PDO::FETCH_ASSOC ); if (!$game) { test_fail( 'Stormgate was not found (expected wf_games.slug = stormgate).' ); } $gameId = (int)$game['id']; $gameConfig = json_decode( (string)( $game['config'] ?? '' ), true ); if (!is_array($gameConfig)) { $gameConfig = []; } $timeConfig = isset($gameConfig['time']) && is_array($gameConfig['time']) ? $gameConfig['time'] : []; $timeMode = strtolower( trim( (string)( $timeConfig['mode'] ?? 'none' ) ) ); if ($timeMode !== 'hours') { test_fail( 'This test suite expects Stormgate Game Time to use Hours.' ); } $oldCoinId = test_find_entity( $pdo, $gameId, 'item', 'Old Coin' ); $haperId = test_find_entity( $pdo, $gameId, 'item', 'Haper Flower' ); $maraId = test_find_entity( $pdo, $gameId, 'character', 'Mara' ); /* |-------------------------------------------------------------------------- | Seed |-------------------------------------------------------------------------- */ test_out('Wayfarer Feature Test Suite'); test_out(''); test_out('Preflight passed.'); test_out('Game: ' . (string)$game['name'] . ' (ID ' . $gameId . ')'); test_out('Old Coin entity ID: ' . $oldCoinId); test_out('Haper Flower entity ID: ' . $haperId); test_out('Mara entity ID: ' . $maraId); test_out(''); test_out('Seeding test Storylets...'); try { $pdo->beginTransaction(); /* * Remove only this temporary test suite. */ $delete = $pdo->prepare( " DELETE FROM wf_storylets WHERE game_id = ? AND storylet_key LIKE 'test-suite-%' " ); $delete->execute([ $gameId ]); /* * ROOT: feature lab. */ $hubId = test_insert_storylet( $pdo, $gameId, '[TEST] Wayfarer Feature Lab', 'test-suite-feature-lab', 'This is a temporary test Storylet for recent Wayfarer features. Choose one of the Branches below.', test_base_config([ 'priority' => 999, ]) ); /* * ROOT destination for V82 transition. */ $hiddenId = test_insert_storylet( $pdo, $gameId, '[TEST] Hidden Chamber', 'test-suite-hidden-chamber', 'SUCCESS: the Branch connected directly to this ROOT Storylet. If you offered the coin, its inventory Effects should already have been applied.', test_base_config([ 'priority' => 998, ]) ); /* * Branch 1: * +1 Old Coin, then Continue To the root Feature Lab. */ test_insert_storylet( $pdo, $gameId, '[TEST] Take a Test Coin', 'test-suite-take-coin', 'You pick up a worn silver coin for the test.', test_base_config([ 'effects' => [ test_fixed_effect( 'entity:' . $oldCoinId, 'trait', 'sys_item_quantity', 'add', 1, 'quantity' ), test_continue_effect( $hubId ), ], ]), $hubId, '1. Take a test coin (+1 Old Coin) and return here', 10 ); /* * Branch 2: * Requires Old Coin >= 1. * -1 Old Coin, +3 Haper Flower, then Continue To Hidden Chamber root. */ test_insert_storylet( $pdo, $gameId, '[TEST] Offer the Coin', 'test-suite-offer-coin', 'You place the old coin into the bowl. The stone door opens.', test_base_config([ 'conditions' => test_condition( 'entity:' . $oldCoinId, 'trait', 'sys_item_quantity', 'greater_than_or_equal', 1 ), 'effects' => [ test_fixed_effect( 'entity:' . $oldCoinId, 'trait', 'sys_item_quantity', 'subtract', 1, 'quantity' ), test_fixed_effect( 'entity:' . $haperId, 'trait', 'sys_item_quantity', 'add', 3, 'quantity' ), test_continue_effect( $hiddenId ), ], ]), $hubId, '2. Offer the Old Coin (requires at least 1)', 20 ); /* * Branch 3: * V79 random possessed-item binding + Random Min/Max. */ test_insert_storylet( $pdo, $gameId, '[TEST] Random Pack Mishap', 'test-suite-random-pack-mishap', 'A strap snaps. {{loot.name}} spills from your pack. The selected item should stay the same everywhere in this Storylet occurrence.', test_base_config([ 'bindings' => [ [ 'key' => 'loot', 'label' => 'Lost Supply', 'selector' => 'random_possessed_item', ], ], 'effects' => [ test_random_effect( 'binding:loot', 'trait', 'sys_item_quantity', 'subtract', 1, 2, 'quantity' ), ], ]), $hubId, '3. Random pack mishap (lose 1–2 of a possessed item)', 30 ); /* * Branch 4: * Party Membership Set (no Random). */ test_insert_storylet( $pdo, $gameId, '[TEST] Recruit Mara', 'test-suite-recruit-mara', 'Mara joins the party. This tests Party Membership as a Set Effect.', test_base_config([ 'conditions' => test_condition( 'entity:' . $maraId, 'trait', 'sys_character_party_member', 'equals', 0 ), 'effects' => [ test_fixed_effect( 'entity:' . $maraId, 'trait', 'sys_character_party_member', 'set', 1, 'party_member' ), ], ]), $hubId, '4. Recruit Mara', 40 ); /* * Branch 5: * V79 random party-character binding + narrative placeholder. * Binding availability itself should hide this until there is an * eligible party Character. */ test_insert_storylet( $pdo, $gameId, '[TEST] Random Party Subject', 'test-suite-random-party-subject', '{{victim.name}} was selected as the Storylet subject. The same bound character should remain selected for this Storylet occurrence.', test_base_config([ 'bindings' => [ [ 'key' => 'victim', 'label' => 'Selected Party Member', 'selector' => 'random_party_character', ], ], ]), $hubId, '5. Pick a random party character (recruit Mara first)', 50 ); /* * Branch 6: * V80 Game Clock Hour Condition: before noon. */ test_insert_storylet( $pdo, $gameId, '[TEST] Morning Clock Condition', 'test-suite-morning-clock', 'SUCCESS: this Branch was available because the current Game Clock Hour is before 12.', test_base_config([ 'conditions' => test_condition( 'time', 'clock', 'hour', 'less_than', 12 ), ]), $hubId, '6. Morning-only Branch (Hour < 12)', 60 ); /* * Branch 7: * V80 Game Clock Hour Condition: noon or later. */ test_insert_storylet( $pdo, $gameId, '[TEST] Noon Clock Condition', 'test-suite-noon-clock', 'SUCCESS: this Branch was available because the current Game Clock Hour is 12 or later.', test_base_config([ 'conditions' => test_condition( 'time', 'clock', 'hour', 'greater_than_or_equal', 12 ), ]), $hubId, '7. Noon-or-later Branch (Hour >= 12)', 70 ); /* * Hidden Chamber child: * another V82 Continue To test back to the Feature Lab root. */ test_insert_storylet( $pdo, $gameId, '[TEST] Return to Feature Lab', 'test-suite-return-to-lab', 'You return to the test lab.', test_base_config([ 'effects' => [ test_continue_effect( $hubId ), ], ]), $hiddenId, 'Return to the Feature Lab', 10 ); /* * Verify before committing. */ $verify = $pdo->prepare( " SELECT id, name, storylet_key, parent_storylet_id, branch_label, status FROM wf_storylets WHERE game_id = ? AND storylet_key LIKE 'test-suite-%' ORDER BY COALESCE(parent_storylet_id, 0), branch_sort_order, id " ); $verify->execute([ $gameId ]); $created = $verify->fetchAll( PDO::FETCH_ASSOC ); if (count($created) !== 10) { throw new RuntimeException( 'Expected 10 test Storylets, but found ' . count($created) . '.' ); } $pdo->commit(); } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } test_fail( $exception->getMessage() . PHP_EOL . 'The transaction was rolled back.' ); } /* |-------------------------------------------------------------------------- | Report |-------------------------------------------------------------------------- */ test_out(''); test_out('Test suite created successfully.'); test_out(''); test_out('Created Storylets:'); foreach ($created as $row) { $parent = (int)( $row['parent_storylet_id'] ?? 0 ); test_out( '- ID ' . (int)$row['id'] . ': ' . (string)$row['name'] . ( $parent > 0 ? ' [Branch of ' . $parent . ']' : ' [ROOT]' ) ); } test_out(''); test_out('Suggested playthrough:'); test_out(''); test_out('1. Start/restart Stormgate.'); test_out('2. Open "[TEST] Wayfarer Feature Lab".'); test_out('3. Choose "Take a test coin".'); test_out(' Expected: +1 Old Coin, then automatic return to the Feature Lab.'); test_out('4. Choose "Offer the Old Coin".'); test_out(' Expected: -1 Old Coin, +3 Haper Flower, then automatic transition'); test_out(' to the ROOT "[TEST] Hidden Chamber" Storylet.'); test_out('5. Choose "Return to the Feature Lab".'); test_out('6. Try "Random pack mishap".'); test_out(' Expected: {{loot.name}} resolves and 1-2 of that possessed item is lost.'); test_out('7. Choose "Recruit Mara", dismiss that Branch, reopen the Feature Lab,'); test_out(' then choose "Pick a random party character".'); test_out(' Expected: {{victim.name}} resolves to the selected party Character.'); test_out('8. Before Hour 12, Morning-only should be available. At/after Hour 12,'); test_out(' Noon-or-later should be available instead.'); test_out(''); test_out('Re-running this seeder will replace only Storylets with keys beginning'); test_out('"test-suite-".'); test_out(''); test_out('You can now delete this PHP seeder from the server.'); ================================================================================ FILE: update-backups/v56-20260925-194709/admin/builder.php ================================================================================ prepare(" SELECT id, name, config FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; // ================================================================ // SAVE / DELETE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { /* * Media Library associations are applied only after the authored * object has saved successfully and therefore has a stable ID. */ $savedRecordId = (int)$saveResult['id']; $mediaResult = applyBuilderMediaSelections( $pdo, $gameId, $definition['table'], $savedRecordId, $_POST, $_FILES ); if (!$mediaResult['saved']) { /* * The authored object itself has already saved. Keep the * editor on that record and report the media failure * accurately rather than pretending the object save failed. */ $id = $savedRecordId; $record['id'] = $savedRecordId; $error = 'Record saved, but media was not changed: ' . $mediaResult['error']; } else { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) . '&id=' . (int)$saveResult['id'] . '&tab=' . rawurlencode( (string)($_POST['active_tab'] ?? 'basics') ) ); exit; } } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder

================================================================================ FILE: update-backups/v56-20260925-194709/assets/css/builder.css ================================================================================ /* |-------------------------------------------------------------------------- | Wayfarer Builder |-------------------------------------------------------------------------- | Master/detail authored-object workspace. | This stylesheet is intentionally presentation-only. */ :root { --wf-border: #d8dbe2; --wf-border-strong: #b8bdc7; --wf-surface: #ffffff; --wf-surface-soft: #f6f7f9; --wf-text: #20242a; --wf-muted: #667085; --wf-accent: #2f5ea8; --wf-accent-soft: #edf3fc; --wf-danger: #a61b2b; --wf-danger-soft: #fff4f5; --wf-radius: 8px; --wf-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); } .wayfarer-builder-page { margin: 0; padding: 24px; background: #f3f4f6; color: var(--wf-text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.45; } .wayfarer-builder-page > h1, .wayfarer-builder-page > p, .wayfarer-builder-page > nav, .wayfarer-builder-page > .builder-workspace, .wayfarer-builder-page > .builder-back-link { max-width: 1400px; margin-left: auto; margin-right: auto; } .wayfarer-builder-page > h1 { margin-bottom: 2px; } .wayfarer-builder-page > h1 + p { margin-top: 0; color: var(--wf-muted); } .wayfarer-builder-page > nav { margin-top: 18px; margin-bottom: 18px; padding: 10px 14px; background: var(--wf-surface); border: 1px solid var(--wf-border); border-radius: var(--wf-radius); box-shadow: var(--wf-shadow); } .wayfarer-builder-page > nav a, .wayfarer-builder-page > nav strong { display: inline-block; margin-right: 18px; } .wayfarer-builder-page > hr { display: none; } /* ------------------------------------------------------------------ MASTER / DETAIL ------------------------------------------------------------------ */ .builder-workspace { display: grid; grid-template-columns: minmax(260px, 340px) minmax(0, 1fr); gap: 18px; align-items: start; } .builder-collection-pane, .builder-editor-pane { background: var(--wf-surface); border: 1px solid var(--wf-border); border-radius: var(--wf-radius); box-shadow: var(--wf-shadow); } .builder-collection-pane { position: sticky; top: 18px; max-height: calc(100vh - 36px); overflow: hidden; } .builder-collection-toolbar { padding: 14px; border-bottom: 1px solid var(--wf-border); background: var(--wf-surface-soft); } .builder-new-object { display: inline-block; padding: 8px 11px; border: 1px solid var(--wf-border-strong); border-radius: 6px; background: var(--wf-surface); text-decoration: none; font-weight: 600; } .builder-collection-scroll { overflow: auto; max-height: calc(100vh - 92px); padding: 14px; } .builder-collection-scroll > h2 { margin-top: 0; margin-bottom: 12px; font-size: 1.15rem; } .builder-collection-scroll ul { margin: 0; padding: 0; list-style: none; } .builder-collection-scroll li + li { margin-top: 4px; } .builder-collection-scroll li a { display: block; padding: 8px 9px; border-radius: 5px; text-decoration: none; } .builder-collection-scroll li a:hover { background: var(--wf-accent-soft); } .builder-collection-scroll table { width: 100%; border-collapse: collapse; font-size: 0.88rem; } .builder-collection-scroll th, .builder-collection-scroll td { padding: 7px 6px; border: 1px solid var(--wf-border); text-align: left; vertical-align: top; } .builder-collection-scroll th button { border: 0; padding: 0; background: transparent; font: inherit; font-weight: 600; cursor: pointer; } .builder-editor-pane { min-width: 0; padding: 20px 24px 24px; } .builder-editor-pane > h2 { margin-top: 0; margin-bottom: 14px; } /* WAYFARER_OBJECT_CONTEXT_HEADING_V34 */ .builder-object-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 8px; } .builder-object-context { color: var(--wf-muted); font-weight: 600; } .builder-object-context[hidden] { display: none; } .builder-object-context-separator { margin-right: 8px; color: var(--wf-border-strong); } /* ------------------------------------------------------------------ TABBED OBJECT EDITOR ------------------------------------------------------------------ */ .builder-object-form { margin: 0; } .builder-editor-tabs { display: flex; gap: 4px; margin: 0 0 20px; padding: 0; border-bottom: 1px solid var(--wf-border); } .builder-editor-tab { position: relative; bottom: -1px; padding: 9px 14px; border: 1px solid transparent; border-bottom: 1px solid var(--wf-border); background: transparent; color: var(--wf-muted); font: inherit; font-weight: 600; cursor: pointer; } .builder-editor-tab:hover { color: var(--wf-text); background: var(--wf-surface-soft); } .builder-editor-tab.is-active { color: var(--wf-accent); background: var(--wf-surface); border-color: var(--wf-border) var(--wf-border) var(--wf-surface); border-radius: 6px 6px 0 0; } .builder-tab-panel[hidden] { display: none !important; } .builder-tab-panel > p:first-child { margin-top: 0; } /* ------------------------------------------------------------------ EXISTING FIELD CONTROLS ------------------------------------------------------------------ */ .builder-editor-pane label { font-weight: 600; } .builder-editor-pane small { color: var(--wf-muted); font-weight: 400; } .builder-editor-pane input[type="text"], .builder-editor-pane input[type="number"], .builder-editor-pane input[type="file"], .builder-editor-pane select, .builder-editor-pane textarea { box-sizing: border-box; max-width: 100%; margin-top: 4px; padding: 7px 8px; border: 1px solid var(--wf-border-strong); border-radius: 5px; background: var(--wf-surface); color: var(--wf-text); font: inherit; } .builder-editor-pane input[type="text"], .builder-editor-pane textarea { width: min(100%, 720px); } .builder-editor-pane textarea { resize: vertical; } .builder-editor-pane fieldset { margin: 0 0 16px; padding: 14px 16px; border: 1px solid var(--wf-border); border-radius: 7px; } .builder-editor-pane legend { padding: 0 6px; } .builder-editor-pane table { max-width: 100%; border-collapse: collapse; } .builder-editor-pane th, .builder-editor-pane td { padding: 7px; } /* System settings stay visually inside Basics. */ .builder-editor-pane [data-system-trait-settings] { margin: 16px 0; padding: 14px 16px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface-soft); } .builder-editor-pane [data-system-trait-settings] h3 { margin-top: 0; } /* ------------------------------------------------------------------ SAVE / DELETE ------------------------------------------------------------------ */ .builder-save-bar { display: flex; justify-content: flex-end; margin: 22px -24px -24px; padding: 14px 24px; border-top: 1px solid var(--wf-border); background: var(--wf-surface-soft); border-radius: 0 0 var(--wf-radius) var(--wf-radius); } .builder-save-bar button { padding: 9px 16px; border: 1px solid #214b86; border-radius: 6px; background: var(--wf-accent); color: #fff; font: inherit; font-weight: 600; cursor: pointer; } .builder-editor-pane [data-builder-delete-form] { margin-top: 28px; padding: 16px; border: 1px solid #e8b8be; border-radius: 7px; background: var(--wf-danger-soft); } .builder-editor-pane [data-builder-delete-form] h3 { margin-top: 0; color: var(--wf-danger); } .builder-editor-pane [data-builder-delete-form] button { padding: 8px 12px; border: 1px solid var(--wf-danger); border-radius: 5px; background: #fff; color: var(--wf-danger) !important; font: inherit; font-weight: 600; cursor: pointer; } .builder-back-link { margin-top: 18px; } /* ------------------------------------------------------------------ RESPONSIVE ------------------------------------------------------------------ */ @media (max-width: 900px) { .wayfarer-builder-page { padding: 14px; } .builder-workspace { grid-template-columns: 1fr; } .builder-collection-pane { position: static; max-height: none; } .builder-collection-scroll { max-height: 300px; } .builder-editor-pane { padding: 18px; } .builder-save-bar { margin-left: -18px; margin-right: -18px; margin-bottom: -18px; padding-left: 18px; padding-right: 18px; } } @media (max-width: 560px) { .builder-editor-tabs { overflow-x: auto; } .builder-editor-tab { white-space: nowrap; } } /* |-------------------------------------------------------------------------- | REGION TICK EFFECTS v18 |-------------------------------------------------------------------------- */ .builder-tick-effect-row { display: grid; grid-template-columns: minmax(220px, 2fr) minmax(130px, 1fr) minmax(110px, 1fr) auto; gap: 12px; align-items: end; margin: 0 0 12px; padding: 14px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface-soft); } .builder-tick-effect-row > p { margin: 0; } .builder-tick-effect-row select, .builder-tick-effect-row input { width: 100%; box-sizing: border-box; } .builder-tick-effect-remove { align-self: end; } @media (max-width: 760px) { .builder-tick-effect-row { grid-template-columns: 1fr; } } /* |-------------------------------------------------------------------------- | TICK EFFECT RESULT BRANCHES v28 |-------------------------------------------------------------------------- | WAYFARER_TICK_EFFECT_BRANCHES_V28 */ .builder-tick-effect-row { display: block; margin: 0 0 16px; padding: 16px; } .builder-tick-effect-header { display: grid; grid-template-columns: minmax(260px, 1fr) auto; gap: 16px; align-items: end; margin-bottom: 16px; } .builder-tick-effect-target select { display: block; width: 100%; margin-top: 6px; } .builder-tick-result-list { display: grid; gap: 12px; } .builder-tick-result-branch { padding: 14px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface); } .builder-tick-result-branch.is-otherwise { background: var(--wf-surface-soft); } .builder-tick-result-heading { display: flex; justify-content: space-between; gap: 12px; align-items: center; margin-bottom: 12px; } .builder-tick-result-order { display: flex; gap: 6px; align-items: center; } .builder-tick-result-condition { margin-bottom: 10px; } .builder-tick-result-then { margin: 10px 0 8px; } .builder-tick-result-fields { display: grid; grid-template-columns: minmax(160px, 220px) minmax(120px, 180px); gap: 12px; align-items: end; } .builder-tick-result-fields select, .builder-tick-result-fields input { display: block; width: 100%; margin-top: 6px; } .builder-tick-result-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; } .builder-condition-group { padding: 10px; border: 1px solid var(--wf-border); border-radius: 6px; background: var(--wf-surface-soft); } .builder-condition-group-header, .builder-condition-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .builder-condition-children { display: grid; gap: 8px; margin: 10px 0; } .builder-condition-row { display: grid; grid-template-columns: minmax(180px, 1.4fr) minmax(180px, 1fr) minmax(120px, 1fr) auto; gap: 8px; align-items: end; } .builder-condition-row select, .builder-condition-row input, .builder-condition-group-header select { width: 100%; box-sizing: border-box; } @media (max-width: 760px) { .builder-tick-effect-header, .builder-tick-result-fields, .builder-condition-row { grid-template-columns: 1fr; } .builder-tick-result-heading { align-items: flex-start; flex-direction: column; } } /* ------------------------------------------------------------------ STARTING CHOICES WAYFARER_STARTING_CHOICES_V36 ------------------------------------------------------------------ */ .builder-starting-choices-editor { margin-top: 24px; } .builder-starting-choices-heading, .builder-starting-choice-header, .builder-starting-option-header, .builder-starting-effects-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } .builder-starting-choices-heading h3 { margin: 0 0 4px; } .builder-starting-choices-heading p { margin: 0; } .builder-starting-choice { margin-top: 16px; padding: 16px; border: 1px solid var(--wf-border); border-radius: var(--wf-radius); background: var(--wf-surface-soft); } .builder-starting-choice-fields { display: grid; grid-template-columns: minmax(0, 1fr) 140px; gap: 12px; margin-top: 14px; } .builder-starting-choice-fields label, .builder-starting-option-fields label, .builder-starting-effect label { display: grid; gap: 5px; font-weight: 600; } .builder-starting-choice-fields input, .builder-starting-choice-fields select, .builder-starting-option-fields input, .builder-starting-option-fields textarea, .builder-starting-effect input, .builder-starting-effect select { width: 100%; font: inherit; } .builder-starting-options { display: grid; gap: 12px; margin: 16px 0 12px; } .builder-starting-option { padding: 14px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface); } .builder-starting-option-fields { display: grid; grid-template-columns: minmax(180px, 0.7fr) minmax(260px, 1.3fr); gap: 12px; margin-top: 12px; } .builder-starting-effects { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--wf-border); } .builder-starting-effects-heading { margin-bottom: 8px; } .builder-starting-effects-heading small { color: var(--wf-muted); } .builder-starting-effect { display: grid; grid-template-columns: minmax(180px, 1.4fr) minmax(120px, 0.7fr) minmax(100px, 0.6fr) auto; gap: 10px; align-items: end; margin-bottom: 8px; } @media (max-width: 800px) { .builder-starting-choice-fields, .builder-starting-option-fields, .builder-starting-effect { grid-template-columns: 1fr; } } /* ------------------------------------------------------------------ ONE-TIME EFFECTS WAYFARER_ONE_TIME_EFFECTS_EDITOR_V42 ------------------------------------------------------------------ */ .builder-effects-panel > hr { margin: 28px 0; border: 0; border-top: 1px solid var(--wf-border, #d8dbe2); } .builder-effects-list { display: grid; gap: 10px; margin: 14px 0; } .builder-effect-row { display: grid; grid-template-columns: minmax(220px, 1.5fr) minmax(130px, 0.7fr) minmax(110px, 0.6fr) auto; gap: 10px; align-items: end; padding: 12px; border: 1px solid var(--wf-border, #d8dbe2); border-radius: 8px; background: #ffffff; } .builder-effect-row label { display: grid; gap: 5px; } .builder-effect-row select, .builder-effect-row input { width: 100%; } .builder-effect-remove { align-self: end; } @media (max-width: 850px) { .builder-effect-row { grid-template-columns: 1fr; } .builder-effect-remove { justify-self: start; } } /* ------------------------------------------------------------------ GENERALIZED EFFECTS WAYFARER_GENERALIZED_EFFECT_EDITOR_V43 ------------------------------------------------------------------ */ /* * v42 displayed an Effect as one horizontal operation row. v43 Effects own * ordered result branches, so use the same card language as Tick Effects. */ .builder-effect-row { display: block; grid-template-columns: none; padding: 14px; } .builder-effect-row .builder-tick-effect-header { margin-bottom: 12px; } .builder-effect-value-fields { display: grid; grid-template-columns: minmax(120px, 0.75fr) minmax(120px, 0.75fr) minmax(120px, 0.75fr); gap: 10px; align-items: end; } .builder-effect-random-range { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .builder-effect-random-range > label { display: grid; gap: 5px; } [data-effect-fixed-wrap][hidden], [data-effect-random-wrap][hidden], [data-effect-value-mode-wrap][hidden] { display: none !important; } @media (max-width: 850px) { .builder-effect-value-fields { grid-template-columns: 1fr; } .builder-effect-random-range { grid-template-columns: 1fr; } } ================================================================================ FILE: update-backups/v56-20260925-194709/assets/js/builder.js ================================================================================ const wayfarerConditionData = window.wayfarerConditionData || {}; function updateConditionalConfigFields() { document .querySelectorAll('[data-config-field]') .forEach(function (container) { const relationshipName = container.dataset.conditionRelationship; const requiredValue = container.dataset.conditionEquals; if (!relationshipName) { container.style.display = ''; return; } const relationshipSelect = document.querySelector( '[data-relationship="' + relationshipName + '"]' ); if (!relationshipSelect) { container.style.display = 'none'; return; } const selectedId = relationshipSelect.value; const actualValue = wayfarerConditionData[relationshipName]?.[selectedId]; container.style.display = actualValue === requiredValue ? '' : 'none'; }); } document .querySelectorAll('[data-relationship]') .forEach(function (select) { select.addEventListener( 'change', updateConditionalConfigFields ); }); // ================================================================ // SYSTEM TRAIT GROUPS // ================================================================ function updateSystemTraitGroups(container) { const selectorField = container.dataset.selectorField; if (!selectorField) { return; } const selector = document.querySelector( '[name="' + selectorField + '"]' ); if (!selector) { return; } const selectedGroup = selector.value; container .querySelectorAll('[data-system-trait-group]') .forEach(function (group) { const active = group.dataset.systemTraitGroup === selectedGroup; group.style.display = active ? '' : 'none'; group .querySelectorAll('input, select, textarea') .forEach(function (input) { input.disabled = !active; }); }); } document .querySelectorAll('[data-system-trait-settings]') .forEach(function (container) { const selectorField = container.dataset.selectorField; const selector = selectorField ? document.querySelector( '[name="' + selectorField + '"]' ) : null; if (selector) { selector.addEventListener( 'change', function () { updateSystemTraitGroups(container); } ); } updateSystemTraitGroups(container); }); // ================================================================ // COLLECTION SORTING // ================================================================ document .querySelectorAll('[data-sort-column]') .forEach(function (button) { button.addEventListener('click', function () { const table = button.closest('[data-collection-table]'); if (!table) { return; } const tbody = table.querySelector('tbody'); const columnName = button.dataset.sortColumn; const sortType = button.dataset.sortType || 'text'; const currentDirection = button.dataset.sortDirection || 'none'; const nextDirection = currentDirection === 'asc' ? 'desc' : 'asc'; table .querySelectorAll('[data-sort-column]') .forEach(function (otherButton) { otherButton.dataset.sortDirection = 'none'; }); button.dataset.sortDirection = nextDirection; const rows = Array.from(tbody.querySelectorAll('tr')); rows.sort(function (a, b) { const aCell = a.querySelector( '[data-column="' + columnName + '"]' ); const bCell = b.querySelector( '[data-column="' + columnName + '"]' ); const aValue = aCell?.dataset.sortValue ?? ''; const bValue = bCell?.dataset.sortValue ?? ''; let comparison = 0; if (sortType === 'number') { const aNumber = aValue === '' ? Number.POSITIVE_INFINITY : Number(aValue); const bNumber = bValue === '' ? Number.POSITIVE_INFINITY : Number(bValue); comparison = aNumber - bNumber; } else { comparison = aValue.localeCompare( bValue, undefined, { numeric: true, sensitivity: 'base' } ); } return nextDirection === 'asc' ? comparison : -comparison; }); rows.forEach(function (row) { tbody.appendChild(row); }); }); }); updateConditionalConfigFields(); // ================================================================ // MEDIA LIBRARY CONTROLS // ================================================================ function updateBuilderMediaSlot(slotContainer) { const checkedAction = slotContainer.querySelector( 'input[type="radio"][name^="media_action"]:checked' ); const action = checkedAction ? checkedAction.value : 'none'; const existingSelect = slotContainer.querySelector( '[data-media-existing]' ); if (existingSelect) { existingSelect.disabled = action !== 'existing'; } slotContainer .querySelectorAll( '[data-media-upload-fields] input' ) .forEach(function (input) { input.disabled = action !== 'upload'; }); } document .querySelectorAll('[data-media-slot]') .forEach(function (slotContainer) { slotContainer .querySelectorAll( 'input[type="radio"][name^="media_action"]' ) .forEach(function (radio) { radio.addEventListener( 'change', function () { updateBuilderMediaSlot( slotContainer ); } ); }); updateBuilderMediaSlot( slotContainer ); }); // ================================================================ // AUTHORED OBJECT CONTEXT HEADING // WAYFARER_OBJECT_CONTEXT_HEADING_V34 // ================================================================ function initializeBuilderObjectContextHeading() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } const heading = editor.querySelector( '[data-builder-object-heading]' ); if (!heading) { return; } const fieldName = heading.dataset.builderObjectLabelField || 'name'; const form = editor.querySelector( 'form' ); if (!form) { return; } const field = Array .from( form.elements ) .find(function (element) { return ( element && element.name === fieldName ); }); if (!field) { return; } const context = heading.querySelector( '[data-builder-object-context]' ); const label = heading.querySelector( '[data-builder-object-context-label]' ); if (!context || !label) { return; } function refreshContextHeading() { const value = String( field.value || '' ).trim(); label.textContent = value; context.hidden = value === ''; } field.addEventListener( 'input', refreshContextHeading ); field.addEventListener( 'change', refreshContextHeading ); refreshContextHeading(); } initializeBuilderObjectContextHeading(); // ================================================================ // AUTHORED OBJECT EDITOR TABS // ================================================================ function initializeBuilderEditorTabs() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } /* * The editor component has one primary save form. The separate delete * form, when present, lives later in the editor pane and is deliberately * left outside the tab system. */ const form = Array .from(editor.children) .find(function (child) { return child.tagName === 'FORM'; }); if ( !form || form.dataset.builderTabbed === '1' ) { return; } form.dataset.builderTabbed = '1'; form.classList.add( 'builder-object-form' ); /* * WAYFARER_ACTIVE_TAB_V20 * * Persist the selected editor tab through POST/redirect/reload. */ const activeTabInput = document.createElement( 'input' ); activeTabInput.type = 'hidden'; activeTabInput.name = 'active_tab'; const requestedTab = new URLSearchParams( window.location.search ).get( 'tab' ) || 'basics'; activeTabInput.value = requestedTab; form.appendChild( activeTabInput ); const originalChildren = Array.from( form.children ); const panels = { basics: document.createElement('div'), media: document.createElement('div'), traits: document.createElement('div'), /* WAYFARER_CONDITIONS_PANEL_V45_1 */ conditions: document.createElement('div'), tick_effects: document.createElement('div') }; panels.basics.className = 'builder-tab-panel'; panels.media.className = 'builder-tab-panel'; panels.traits.className = 'builder-tab-panel'; panels.conditions.className = 'builder-tab-panel'; panels.basics.dataset.builderPanel = 'basics'; panels.media.dataset.builderPanel = 'media'; panels.traits.dataset.builderPanel = 'traits'; panels.conditions.dataset.builderPanel = 'conditions'; panels.tick_effects.className = 'builder-tab-panel'; panels.tick_effects.dataset.builderPanel = 'tick_effects'; let section = 'basics'; let saveContainer = null; let hasMedia = false; let hasTraits = false; let hasConditions = false; let hasTickEffects = false; originalChildren.forEach( function (child) { /* * Keep hidden record identifiers directly in the form. */ if ( child.tagName === 'INPUT' && child.type === 'hidden' ) { return; } /* * Save becomes a stable footer beneath the tab panels. */ const submitButton = child.querySelector ? child.querySelector( 'button[type="submit"], input[type="submit"]' ) : null; if (submitButton) { saveContainer = child; return; } /* * Ignore the separator immediately before a tab heading. * Other HR elements are harmless but unnecessary inside the * cleaned-up editor. */ if (child.tagName === 'HR') { child.remove(); return; } if (child.tagName === 'H3') { const heading = child.textContent .trim() .toLowerCase(); if (heading === 'media') { section = 'media'; hasMedia = true; child.remove(); return; } if (heading === 'traits') { section = 'traits'; hasTraits = true; child.remove(); return; } } /* * WAYFARER_CONDITIONS_PANEL_V45_1 * * Conditions answer whether an authored object is eligible; * Effects answer what changes when it triggers. */ if ( child.hasAttribute && child.hasAttribute( 'data-builder-conditions-panel' ) ) { section = 'conditions'; hasConditions = true; } /* * WAYFARER_ONE_TIME_EFFECTS_EDITOR_V42 * * Location Effects and Tick Effects share one Effects panel. */ if ( child.hasAttribute && ( child.hasAttribute( 'data-builder-effects-panel' ) || child.hasAttribute( 'data-builder-tick-effects' ) ) ) { section = 'tick_effects'; hasTickEffects = true; } /* * Config fields belong to Basics even though the old v9 Media * markup happens to precede them in the source template. */ if ( child.hasAttribute && child.hasAttribute( 'data-config-field' ) ) { section = 'basics'; } /* * A Media fieldset is definitive even if future markup changes * around the Media heading. */ if ( child.hasAttribute && child.hasAttribute( 'data-media-slot' ) ) { section = 'media'; hasMedia = true; } panels[section] .appendChild( child ); } ); /* * If Traits exist, anything moved after the Traits heading belongs there. * System traits do not use that heading and therefore stay in Basics. */ if ( panels.traits.children.length > 0 ) { hasTraits = true; } if ( panels.media.children.length > 0 ) { hasMedia = true; } if ( panels.conditions.children.length > 0 ) { hasConditions = true; } if ( panels.tick_effects.children.length > 0 ) { hasTickEffects = true; } const tabBar = document.createElement( 'div' ); tabBar.className = 'builder-editor-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const availablePanels = [ { key: 'basics', label: 'Basics', show: true }, { key: 'media', label: 'Media', show: hasMedia }, { key: 'traits', label: 'Traits', show: hasTraits }, { key: 'conditions', label: 'Conditions', show: hasConditions }, { key: 'tick_effects', label: 'Effects', show: hasTickEffects } ].filter( function (item) { return item.show; } ); function activatePanel(panelKey) { activeTabInput.value = panelKey; tabBar .querySelectorAll( '[data-builder-tab]' ) .forEach( function (button) { const active = button.dataset.builderTab === panelKey; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); Object .entries(panels) .forEach( function (entry) { const key = entry[0]; const panel = entry[1]; panel.hidden = key !== panelKey; } ); } availablePanels.forEach( function (item) { const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'builder-editor-tab'; button.dataset.builderTab = item.key; button.setAttribute( 'role', 'tab' ); button.textContent = item.label; button.addEventListener( 'click', function () { activatePanel( item.key ); } ); tabBar.appendChild( button ); } ); /* * Insert the new structure after hidden inputs so all form fields remain * part of the same form and submit exactly as before. */ form.appendChild( tabBar ); availablePanels.forEach( function (item) { form.appendChild( panels[item.key] ); } ); if (saveContainer) { saveContainer.classList.add( 'builder-save-bar' ); form.appendChild( saveContainer ); } const initialPanelKey = availablePanels.some( function (item) { return item.key === requestedTab; } ) ? requestedTab : 'basics'; activatePanel( initialPanelKey ); } initializeBuilderEditorTabs(); // ================================================================ // SHARED GAMEPLAY TICK EFFECTS CONTROLS v25 // WAYFARER_GAMEPLAY_MODULAR_V25 // ================================================================ function initializeTickEffects() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } const wrapper = editor.querySelector( '[data-builder-tick-effects]' ); if (!wrapper) { return; } if ( wrapper.dataset.tickEffectsReady === '1' ) { return; } wrapper.dataset.tickEffectsReady = '1'; // ------------------------------------------------------------ // ROW ADD / REMOVE // ------------------------------------------------------------ const list = wrapper.querySelector( '[data-tick-effects-list]' ); const addButton = wrapper.querySelector( '[data-add-tick-effect]' ); const template = wrapper.querySelector( '[data-tick-effect-template]' ); let nextIndex = 0; wrapper .querySelectorAll( '[data-tick-effect-row]' ) .forEach( function (row) { const index = Number( row.dataset.tickIndex ); if ( Number.isFinite(index) && index >= nextIndex ) { nextIndex = index + 1; } } ); function updateLinearTargetAvailability() { const navigationSelect = editor.querySelector( 'select[name="navigation_type"]' ); const navigationType = navigationSelect ? navigationSelect.value : wrapper.dataset.navigationType; const linear = navigationType === 'linear'; wrapper .querySelectorAll( '[data-linear-navigation-target]' ) .forEach( function (option) { option.disabled = !linear; } ); if (!linear) { wrapper .querySelectorAll( '[data-tick-effect-target]' ) .forEach( function (select) { if ( select.value === 'navigation:position' ) { select.value = ''; } } ); } } if ( addButton && list && template ) { addButton.addEventListener( 'click', function () { const html = template.innerHTML.replace( /__INDEX__/g, String( nextIndex++ ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const row = temporary.firstElementChild; if (!row) { return; } list.appendChild( row ); updateLinearTargetAvailability(); } ); } wrapper.addEventListener( 'click', function (event) { const removeButton = event.target.closest( '[data-remove-tick-effect]' ); if (!removeButton) { return; } const row = removeButton.closest( '[data-tick-effect-row]' ); if (row) { row.remove(); } } ); const navigationSelect = editor.querySelector( 'select[name="navigation_type"]' ); if (navigationSelect) { navigationSelect.addEventListener( 'change', updateLinearTargetAvailability ); } updateLinearTargetAvailability(); /* * WAYFARER_EMBEDDED_TICK_EFFECTS_V33 * * Game Settings already owns a Gameplay tab. In embedded mode we keep * this shared editor exactly where PHP rendered it while retaining the * normal Tick Effect, condition, and result-branch controls. */ if ( wrapper.hasAttribute( 'data-tick-effects-embedded' ) ) { return; } // ------------------------------------------------------------ // MOVE THE SECTION INTO ITS OWN BUILDER TAB // ------------------------------------------------------------ const form = wrapper.closest( 'form' ); if (!form) { return; } const tabBar = form.querySelector( '.builder-editor-tabs' ); if (!tabBar) { /* * Builder without the v10 tab layout: * leave the Tick Effects section inline. */ return; } if ( form.querySelector( '[data-builder-panel="tick_effects"]' ) ) { return; } const panel = document.createElement( 'div' ); panel.className = 'builder-tab-panel'; panel.dataset.builderPanel = 'tick_effects'; panel.hidden = true; panel.appendChild( wrapper ); const saveBar = form.querySelector( '.builder-save-bar' ); if (saveBar) { form.insertBefore( panel, saveBar ); } else { form.appendChild( panel ); } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'builder-editor-tab'; button.dataset.builderTab = 'tick_effects'; button.setAttribute( 'role', 'tab' ); button.setAttribute( 'aria-selected', 'false' ); /* WAYFARER_GAMEPLAY_TAB_LABEL_V25_1 */ button.textContent = 'Effects'; tabBar.appendChild( button ); function activateTickEffectsTab() { tabBar .querySelectorAll( '[data-builder-tab]' ) .forEach( function (tabButton) { const active = tabButton === button; tabButton.classList.toggle( 'is-active', active ); tabButton.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); form .querySelectorAll( '[data-builder-panel]' ) .forEach( function (tabPanel) { tabPanel.hidden = tabPanel.dataset.builderPanel !== 'tick_effects'; } ); } button.addEventListener( 'click', activateTickEffectsTab ); /* * The original tab controller does not know about the panel added * afterward, so explicitly hide our panel whenever an original tab * is clicked. */ tabBar .querySelectorAll( '[data-builder-tab]:not([data-builder-tab="tick_effects"])' ) .forEach( function (originalButton) { originalButton.addEventListener( 'click', function () { panel.hidden = true; button.classList.remove( 'is-active' ); button.setAttribute( 'aria-selected', 'false' ); } ); } ); } // ================================================================ // SHARED ONE-TIME EFFECTS CONTROLS // WAYFARER_ONE_TIME_EFFECTS_EDITOR_V42 // WAYFARER_GENERALIZED_EFFECT_EDITOR_V43 // ================================================================ function wayfarerEffectConditionTargets( wrapper ) { const targetJson = wrapper.querySelector( '[data-effect-condition-targets-json]' ); if (!targetJson) { return {}; } try { return JSON.parse( targetJson.textContent || '{}' ); } catch (error) { return {}; } } function wayfarerEffectBuildConditionEditor( host, name, targets ) { const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( name, targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } function wayfarerEffectUpdateResultFields( branch ) { const operation = branch.querySelector( '[data-effect-branch-operation]' ); const modeWrap = branch.querySelector( '[data-effect-value-mode-wrap]' ); const mode = branch.querySelector( '[data-effect-value-mode]' ); const fixedWrap = branch.querySelector( '[data-effect-fixed-wrap]' ); const randomWrap = branch.querySelector( '[data-effect-random-wrap]' ); if (!operation) { return; } const noChange = operation.value === 'none'; if (modeWrap) { modeWrap.hidden = noChange; modeWrap .querySelectorAll( 'select, input' ) .forEach( function (field) { field.disabled = noChange; } ); } const valueMode = mode ? mode.value : 'fixed'; if (fixedWrap) { fixedWrap.hidden = noChange || valueMode !== 'fixed'; fixedWrap .querySelectorAll( 'input' ) .forEach( function (field) { field.disabled = noChange || valueMode !== 'fixed'; } ); } if (randomWrap) { randomWrap.hidden = noChange || valueMode !== 'random'; randomWrap .querySelectorAll( 'input' ) .forEach( function (field) { field.disabled = noChange || valueMode !== 'random'; } ); } } function wayfarerEffectReindexBranches( effectRow ) { const effectIndex = effectRow.dataset.effectIndex; const list = effectRow.querySelector( '[data-effect-branch-list]' ); if ( effectIndex === undefined || !list ) { return; } const branches = Array .from( list.children ) .filter( function (child) { return ( child.matches && child.matches( '[data-effect-branch]' ) ); } ); branches.forEach( function ( branch, branchIndex ) { branch.dataset.branchIndex = String( branchIndex ); branch .querySelectorAll( '[name]' ) .forEach( function (field) { field.name = field.name.replace( /effects\[\d+\]\[branches\]\[\d+\]/, 'effects[' + effectIndex + '][branches][' + branchIndex + ']' ); } ); } ); } function wayfarerEffectRefreshBranchControls( effectRow ) { const list = effectRow.querySelector( '[data-effect-branch-list]' ); if (!list) { return; } const branches = Array .from( list.children ) .filter( function (child) { return ( child.matches && child.matches( '[data-effect-branch]' ) ); } ); const conditionals = branches.filter( function (branch) { return ( branch.dataset.branchKind === 'conditional' ); } ); conditionals.forEach( function ( branch, index ) { const up = branch.querySelector( '[data-move-effect-branch-up]' ); const down = branch.querySelector( '[data-move-effect-branch-down]' ); if (up) { up.disabled = index === 0; } if (down) { down.disabled = index === conditionals.length - 1; } } ); const hasOtherwise = branches.some( function (branch) { return ( branch.dataset.branchKind === 'otherwise' ); } ); const addOtherwise = effectRow.querySelector( '[data-add-effect-otherwise]' ); if (addOtherwise) { addOtherwise.disabled = hasOtherwise; } branches.forEach( wayfarerEffectUpdateResultFields ); wayfarerEffectReindexBranches( effectRow ); } function initializeEffects() { document .querySelectorAll( '[data-builder-effects]' ) .forEach( function (wrapper) { if ( wrapper.dataset.effectsReady === '1' ) { return; } wrapper.dataset.effectsReady = '1'; const list = wrapper.querySelector( '[data-effects-list]' ); const addButton = wrapper.querySelector( '[data-add-effect]' ); const effectTemplate = wrapper.querySelector( '[data-effect-template]' ); const conditionalTemplate = wrapper.querySelector( '[data-effect-conditional-template]' ); const otherwiseTemplate = wrapper.querySelector( '[data-effect-otherwise-template]' ); const conditionTargets = wayfarerEffectConditionTargets( wrapper ); let nextIndex = 0; wrapper .querySelectorAll( '[data-effect-row]' ) .forEach( function (row) { const index = Number( row.dataset.effectIndex ); if ( Number.isFinite( index ) && index >= nextIndex ) { nextIndex = index + 1; } wayfarerEffectRefreshBranchControls( row ); } ); if ( addButton && list && effectTemplate ) { addButton.addEventListener( 'click', function () { const effectIndex = nextIndex++; const html = effectTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', String( effectIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const row = temporary.firstElementChild; if (!row) { return; } list.appendChild( row ); wayfarerEffectRefreshBranchControls( row ); } ); } wrapper.addEventListener( 'change', function (event) { const operation = event.target.closest( '[data-effect-branch-operation]' ); const valueMode = event.target.closest( '[data-effect-value-mode]' ); if ( !operation && !valueMode ) { return; } const branch = event.target.closest( '[data-effect-branch]' ); if (branch) { wayfarerEffectUpdateResultFields( branch ); } } ); wrapper.addEventListener( 'click', function (event) { const effectRow = event.target.closest( '[data-effect-row]' ); const removeEffect = event.target.closest( '[data-remove-effect]' ); if (removeEffect) { if (!effectRow) { return; } if ( !window.confirm( 'Remove this Effect?' ) ) { return; } effectRow.remove(); return; } if (!effectRow) { return; } const addConditional = event.target.closest( '[data-add-effect-conditional]' ); if (addConditional) { if (!conditionalTemplate) { return; } const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if (!branchList) { return; } const effectIndex = effectRow.dataset.effectIndex; const branchIndex = branchList .querySelectorAll( ':scope > [data-effect-branch]' ) .length; const html = conditionalTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } const otherwise = Array .from( branchList.children ) .find( function (child) { return ( child.dataset && child.dataset.branchKind === 'otherwise' ); } ); if (otherwise) { branchList.insertBefore( branch, otherwise ); } else { branchList.appendChild( branch ); } wayfarerEffectReindexBranches( effectRow ); const newIndex = branch.dataset.branchIndex; const host = branch.querySelector( '[data-new-effect-condition-editor]' ); if (host) { wayfarerEffectBuildConditionEditor( host, 'effects[' + effectIndex + '][branches][' + newIndex + '][conditions]', conditionTargets ); } wayfarerEffectRefreshBranchControls( effectRow ); return; } const addOtherwise = event.target.closest( '[data-add-effect-otherwise]' ); if (addOtherwise) { if (!otherwiseTemplate) { return; } const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if ( !branchList || branchList.querySelector( '[data-branch-kind="otherwise"]' ) ) { return; } const effectIndex = effectRow.dataset.effectIndex; const branchIndex = branchList .querySelectorAll( ':scope > [data-effect-branch]' ) .length; const html = otherwiseTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } branchList.appendChild( branch ); wayfarerEffectRefreshBranchControls( effectRow ); return; } const removeBranch = event.target.closest( '[data-remove-effect-branch]' ); if (removeBranch) { const branch = removeBranch.closest( '[data-effect-branch]' ); if (branch) { branch.remove(); wayfarerEffectRefreshBranchControls( effectRow ); } return; } const moveUp = event.target.closest( '[data-move-effect-branch-up]' ); const moveDown = event.target.closest( '[data-move-effect-branch-down]' ); if ( !moveUp && !moveDown ) { return; } const branch = event.target.closest( '[data-effect-branch]' ); const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if ( !branch || !branchList || branch.dataset.branchKind !== 'conditional' ) { return; } const conditionals = Array .from( branchList.children ) .filter( function (child) { return ( child.dataset && child.dataset.branchKind === 'conditional' ); } ); const index = conditionals.indexOf( branch ); if ( moveUp && index > 0 ) { branchList.insertBefore( branch, conditionals[ index - 1 ] ); } if ( moveDown && index >= 0 && index < conditionals.length - 1 ) { const next = conditionals[ index + 1 ]; branchList.insertBefore( next, branch ); } wayfarerEffectRefreshBranchControls( effectRow ); } ); } ); } initializeEffects(); initializeTickEffects();initializeTickEffects(); // ================================================================ // REUSABLE CONDITION EDITOR v27 // WAYFARER_CONDITION_EDITOR_V27 // ================================================================ function wayfarerConditionEscape(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function wayfarerConditionTargetOptions( targets ) { const groups = {}; Object .entries( targets || {} ) .forEach( function (entry) { const key = entry[0]; const target = entry[1] || {}; const group = target.group || 'Targets'; if (!groups[group]) { groups[group] = []; } groups[group].push({ key: key, label: target.label || key }); } ); let html = ''; Object .entries(groups) .forEach( function (entry) { html += ''; entry[1] .forEach( function (target) { html += ''; } ); html += ''; } ); return html; } function wayfarerConditionNextChildIndex( group ) { let maxIndex = -1; const children = Array .from( group.children ) .find( function (child) { return child.hasAttribute( 'data-condition-children' ); } ); if (children) { Array .from( children.children ) .forEach( function (node) { let index = Number( node.dataset.conditionChildIndex ); if (!Number.isFinite(index)) { const namedInput = node.querySelector( '[name]' ); const match = namedInput ? namedInput.name.match( /\[children\]\[(\d+)\]/ ) : null; index = match ? Number(match[1]) : -1; } if ( Number.isFinite(index) && index > maxIndex ) { maxIndex = index; } } ); } return maxIndex + 1; } function wayfarerConditionCreateCondition( name, targets, childIndex ) { const row = document.createElement( 'div' ); row.className = 'builder-condition-row'; row.dataset.conditionNode = '1'; row.dataset.conditionType = 'condition'; row.dataset.conditionChildIndex = String(childIndex); row.innerHTML = '' + '' + '' + '' + ''; return row; } function wayfarerConditionCreateGroup( name, targets, childIndex, isRoot ) { const group = document.createElement( 'div' ); group.className = 'builder-condition-group' + ( isRoot ? ' builder-condition-group-root' : '' ); group.dataset.conditionNode = '1'; group.dataset.conditionType = 'group'; if ( childIndex !== null && childIndex !== undefined ) { group.dataset.conditionChildIndex = String(childIndex); } group.innerHTML = '' + '
' + '' + ' of the following' + ( isRoot ? '' : '' ) + '
' + '
' + '
' + ' ' + '' + '
'; return group; } function wayfarerConditionInitializeEditor( editor, targets ) { if ( !editor || editor.dataset.conditionEditorReady === '1' ) { return; } editor.dataset.conditionEditorReady = '1'; editor.addEventListener( 'click', function (event) { const removeButton = event.target.closest( '[data-remove-condition-node]' ); if (removeButton) { const node = removeButton.closest( '[data-condition-node]' ); if (node) { node.remove(); } return; } const addCondition = event.target.closest( '[data-add-condition]' ); const addGroup = event.target.closest( '[data-add-condition-group]' ); if ( !addCondition && !addGroup ) { return; } const group = event.target.closest( '[data-condition-type="group"]' ); if (!group) { return; } const children = Array .from( group.children ) .find( function (child) { return child.hasAttribute( 'data-condition-children' ); } ); if (!children) { return; } const groupTypeInput = Array .from( group.children ) .find( function (child) { return ( child.tagName === 'INPUT' && child.type === 'hidden' && child.name.endsWith( '[type]' ) ); } ); if (!groupTypeInput) { return; } const groupName = groupTypeInput.name.replace( /\[type\]$/, '' ); const nextIndex = wayfarerConditionNextChildIndex( group ); const childName = groupName + '[children][' + nextIndex + ']'; const newNode = addGroup ? wayfarerConditionCreateGroup( childName, targets, nextIndex, false ) : wayfarerConditionCreateCondition( childName, targets, nextIndex ); children.appendChild( newNode ); } ); } function initializeConditionEditors() { /* * WAYFARER_GENERIC_CONDITION_INIT_V45_2 * * The Condition Editor is shared infrastructure. It must work anywhere it * is rendered, not only when nested inside Tick Effects. * * Server-rendered editors carry their own allowed-target JSON. Dynamic * Tick Effect / Effect editors continue to initialize themselves through * their existing creation paths. */ document .querySelectorAll( '[data-condition-editor]' ) .forEach( function (editor) { let targets = {}; const targetJson = editor.querySelector( '[data-condition-editor-targets-json]' ); if (targetJson) { try { targets = JSON.parse( targetJson.textContent || '{}' ); } catch (error) { targets = {}; } } wayfarerConditionInitializeEditor( editor, targets ); } ); /* * Tick Effects retain their observer because newly-created Tick Effect * rows are added after page load and are not server-rendered editors. */ document .querySelectorAll( '[data-builder-tick-effects]' ) .forEach( function (wrapper) { let targets = {}; const targetJson = wrapper.querySelector( '[data-condition-targets-json]' ); if (targetJson) { try { targets = JSON.parse( targetJson.textContent || '{}' ); } catch (error) { targets = {}; } } wrapper .querySelectorAll( '[data-condition-editor]' ) .forEach( function (editor) { wayfarerConditionInitializeEditor( editor, targets ); } ); /* * New Tick Effect rows are cloned from a template after page * load, so convert their placeholder into a fresh root group. */ const list = wrapper.querySelector( '[data-tick-effects-list]' ); if ( list && !list.dataset.conditionObserverReady ) { list.dataset.conditionObserverReady = '1'; const observer = new MutationObserver( function (mutations) { mutations.forEach( function (mutation) { mutation.addedNodes .forEach( function (addedNode) { if ( !addedNode.querySelectorAll ) { return; } const row = addedNode.matches && addedNode.matches( '[data-tick-effect-row]' ) ? addedNode : addedNode.querySelector( '[data-tick-effect-row]' ); if (!row) { return; } const host = row.querySelector( '[data-new-condition-editor]' ); if (!host) { return; } const index = row.dataset.tickIndex; const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( 'tick_effects[' + index + '][conditions]', targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } ); } ); } ); observer.observe( list, { childList: true, subtree: true } ); } } ); } initializeConditionEditors(); // ================================================================ // TICK EFFECT RESULT BRANCHES v28 // WAYFARER_TICK_EFFECT_BRANCHES_V28 // ================================================================ function wayfarerTickEffectConditionTargets( wrapper ) { const targetJson = wrapper.querySelector( '[data-condition-targets-json]' ); if (!targetJson) { return {}; } try { return JSON.parse( targetJson.textContent || '{}' ); } catch (error) { return {}; } } function wayfarerTickEffectUpdateBranchValue( branch ) { const operation = branch.querySelector( '[data-tick-branch-operation]' ); const valueWrap = branch.querySelector( '[data-tick-branch-value-wrap]' ); if ( !operation || !valueWrap ) { return; } const input = valueWrap.querySelector( 'input' ); const noChange = operation.value === 'none'; valueWrap.hidden = noChange; if (input) { input.disabled = noChange; } } function wayfarerTickEffectReindexBranches( effectRow ) { const effectIndex = effectRow.dataset.tickIndex; const list = effectRow.querySelector( '[data-tick-branch-list]' ); if ( effectIndex === undefined || !list ) { return; } const branches = Array.from( list.children ).filter( function (child) { return ( child.matches && child.matches( '[data-tick-effect-branch]' ) ); } ); branches.forEach( function ( branch, branchIndex ) { branch.dataset.branchIndex = String( branchIndex ); branch .querySelectorAll( '[name]' ) .forEach( function (field) { field.name = field.name.replace( /tick_effects\[\d+\]\[branches\]\[\d+\]/, 'tick_effects[' + effectIndex + '][branches][' + branchIndex + ']' ); } ); } ); } function wayfarerTickEffectRefreshBranchControls( effectRow ) { const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } const branches = Array.from( list.children ).filter( function (child) { return ( child.matches && child.matches( '[data-tick-effect-branch]' ) ); } ); const conditionalBranches = branches.filter( function (branch) { return ( branch.dataset.branchKind === 'conditional' ); } ); conditionalBranches.forEach( function ( branch, index ) { const up = branch.querySelector( '[data-move-tick-branch-up]' ); const down = branch.querySelector( '[data-move-tick-branch-down]' ); if (up) { up.disabled = index === 0; } if (down) { down.disabled = index === conditionalBranches.length - 1; } } ); const hasOtherwise = branches.some( function (branch) { return ( branch.dataset.branchKind === 'otherwise' ); } ); const addOtherwise = effectRow.querySelector( '[data-add-tick-otherwise-result]' ); if (addOtherwise) { addOtherwise.disabled = hasOtherwise; } branches.forEach( wayfarerTickEffectUpdateBranchValue ); wayfarerTickEffectReindexBranches( effectRow ); } function wayfarerTickEffectBuildConditionEditor( host, name, targets ) { const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( name, targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } function initializeTickEffectResultBranches() { document .querySelectorAll( '[data-builder-tick-effects]' ) .forEach( function (wrapper) { if ( wrapper.dataset.tickBranchesReady === '1' ) { return; } wrapper.dataset.tickBranchesReady = '1'; const conditionalTemplate = wrapper.querySelector( '[data-tick-conditional-branch-template]' ); const otherwiseTemplate = wrapper.querySelector( '[data-tick-otherwise-branch-template]' ); const conditionTargets = wayfarerTickEffectConditionTargets( wrapper ); wrapper .querySelectorAll( '[data-tick-effect-row]' ) .forEach( wayfarerTickEffectRefreshBranchControls ); wrapper.addEventListener( 'change', function (event) { const operation = event.target.closest( '[data-tick-branch-operation]' ); if (!operation) { return; } const branch = operation.closest( '[data-tick-effect-branch]' ); if (branch) { wayfarerTickEffectUpdateBranchValue( branch ); } } ); wrapper.addEventListener( 'click', function (event) { const effectRow = event.target.closest( '[data-tick-effect-row]' ); if (!effectRow) { return; } const addConditional = event.target.closest( '[data-add-tick-conditional-result]' ); if (addConditional) { if (!conditionalTemplate) { return; } const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } const effectIndex = effectRow.dataset.tickIndex; const branchIndex = list.querySelectorAll( ':scope > [data-tick-effect-branch]' ).length; const html = conditionalTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } const otherwise = Array .from( list.children ) .find( function (child) { return ( child.dataset && child.dataset.branchKind === 'otherwise' ); } ); if (otherwise) { list.insertBefore( branch, otherwise ); } else { list.appendChild( branch ); } wayfarerTickEffectReindexBranches( effectRow ); const newIndex = branch.dataset.branchIndex; const host = branch.querySelector( '[data-new-branch-condition-editor]' ); if (host) { wayfarerTickEffectBuildConditionEditor( host, 'tick_effects[' + effectIndex + '][branches][' + newIndex + '][conditions]', conditionTargets ); } wayfarerTickEffectRefreshBranchControls( effectRow ); return; } const addOtherwise = event.target.closest( '[data-add-tick-otherwise-result]' ); if (addOtherwise) { if (!otherwiseTemplate) { return; } const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } if ( list.querySelector( '[data-branch-kind="otherwise"]' ) ) { return; } const effectIndex = effectRow.dataset.tickIndex; const branchIndex = list.querySelectorAll( ':scope > [data-tick-effect-branch]' ).length; const html = otherwiseTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } list.appendChild( branch ); wayfarerTickEffectRefreshBranchControls( effectRow ); return; } const removeBranch = event.target.closest( '[data-remove-tick-branch]' ); if (removeBranch) { const branch = removeBranch.closest( '[data-tick-effect-branch]' ); if (branch) { branch.remove(); wayfarerTickEffectRefreshBranchControls( effectRow ); } return; } const moveUp = event.target.closest( '[data-move-tick-branch-up]' ); const moveDown = event.target.closest( '[data-move-tick-branch-down]' ); if ( !moveUp && !moveDown ) { return; } const branch = event.target.closest( '[data-tick-effect-branch]' ); const list = effectRow.querySelector( '[data-tick-branch-list]' ); if ( !branch || !list || branch.dataset.branchKind !== 'conditional' ) { return; } const conditionals = Array.from( list.children ).filter( function (child) { return ( child.dataset && child.dataset.branchKind === 'conditional' ); } ); const index = conditionals.indexOf( branch ); if ( moveUp && index > 0 ) { list.insertBefore( branch, conditionals[index - 1] ); } if ( moveDown && index >= 0 && index < conditionals.length - 1 ) { const next = conditionals[index + 1]; list.insertBefore( next, branch ); } wayfarerTickEffectRefreshBranchControls( effectRow ); } ); /* * Existing Tick Effect code creates whole effect rows from its * template. Refresh branch controls after those rows appear. */ const list = wrapper.querySelector( '[data-tick-effects-list]' ); if (list) { const observer = new MutationObserver( function (mutations) { mutations.forEach( function (mutation) { mutation.addedNodes .forEach( function (node) { if ( !node.matches || !node.matches( '[data-tick-effect-row]' ) ) { return; } wayfarerTickEffectRefreshBranchControls( node ); } ); } ); } ); observer.observe( list, { childList: true } ); } } ); } initializeTickEffectResultBranches(); ================================================================================ FILE: update-backups/v56-20260925-194709/config/builder-types.php ================================================================================ [ 'label' => 'Regions', 'singular' => 'Region', 'table' => 'wf_regions', 'list_field' => 'name', 'traits' => true, /* * Permanent deletion is available from the edit screen. * A Region cannot be deleted while Locations still belong to it. */ 'delete' => [ 'enabled' => true, 'dependencies' => [ [ 'table' => 'wf_locations', 'foreign_key' => 'region_id', 'label' => 'Locations' ] ] ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'navigation_type' => [ 'label' => 'Navigation Type', 'type' => 'select', 'required' => true, 'options' => [ 'linear' => 'Linear', 'grid' => 'Grid', 'freeform' => 'Freeform' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ] ], 'locations' => [ 'label' => 'Locations', 'singular' => 'Location', 'table' => 'wf_locations', 'list_field' => 'name', 'traits' => true, 'list_columns' => [ 'name' => [ 'label' => 'Name', 'source' => 'field', 'field' => 'name', 'sortable' => true ], 'region' => [ 'label' => 'Region', 'source' => 'relationship', 'relationship' => 'region_id', 'sortable' => true ], 'position' => [ 'label' => 'Position', 'source' => 'config', 'field' => 'position', 'type' => 'number', 'sortable' => true ] ], /* * Permanent deletion is available from the edit screen. */ 'delete' => [ 'enabled' => true ], 'scope' => [ 'type' => 'region', 'field' => 'region_id', 'scope_table' => 'wf_regions', 'scope_key' => 'id', 'game_field' => 'game_id' ], 'relationships' => [ 'region_id' => [ 'label' => 'Region', 'type' => 'select_record', 'table' => 'wf_regions', 'value_field' => 'id', 'label_field' => 'name', 'game_field' => 'game_id', 'required' => true ] ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], 'config_fields' => [ 'position' => [ 'label' => 'Position', 'type' => 'number', 'min' => 0, 'required' => true, 'condition' => [ 'relationship' => 'region_id', 'source_table' => 'wf_regions', 'source_key' => 'id', 'source_field' => 'navigation_type', 'equals' => 'linear' ] ] ] ], 'entities' => [ 'label' => 'Entities', 'singular' => 'Entity', 'table' => 'wf_entities', 'list_field' => 'name', 'traits' => true, /* * Permanent deletion is available from the edit screen. * Additional dependency rules can be added here later as * Storylets and other authored objects begin referencing Entities. */ 'delete' => [ 'enabled' => true ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'entity_type' => [ 'label' => 'Entity Type', 'type' => 'select', 'required' => true, 'default' => 'item', 'options' => [ 'item' => 'Item', 'character' => 'Character' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], /* * System traits use the same stored config.traits collection as * author-created traits. They are locked in the authoring UI. */ 'system_traits' => [ 'selector_field' => 'entity_type', 'after_field' => 'entity_type', 'groups' => [ 'item' => [ 'label' => 'Item Settings', 'help' => 'These are required Item properties used by Wayfarer. ' . 'They cannot be removed or renamed.', 'traits' => [ 'quantity' => [ /* * WAYFARER_ITEM_QUANTITY_CONTEXT_V44 */ 'label' => 'Starting Quantity', 'type' => 'number', 'default' => 0, 'min' => 0, 'required' => true, /* * The minimum is a Wayfarer system rule, not an * author-editable gameplay setting. */ 'show_system_min' => true, 'min_label' => 'Minimum Quantity', 'min_help' => 'Wayfarer Item quantities cannot fall below 0.', 'allow_author_max' => true, 'max_label' => 'Maximum Quantity', 'max_help' => 'The maximum quantity the player can hold during gameplay. ' . 'Leave blank for no maximum.', 'help' => 'Sets the quantity of this Item when gameplay is initialized. ' . 'Effects may increase or decrease the quantity during play. ' . 'A Starting Quantity of 0 means the player begins without this Item.' ], 'inventory_visibility' => [ 'label' => 'Inventory Visibility', 'type' => 'select', 'default' => 'while_possessed', 'required' => true, 'options' => [ 'while_possessed' => 'Only while possessed', 'once_discovered' => 'Once discovered', 'always' => 'Always', 'never' => 'Never' ], 'help' => 'Controls when this item appears in the player inventory. ' . '"Only while possessed" hides the item whenever its quantity ' . 'is 0. "Once discovered" hides it until the player first ' . 'obtains or discovers it, then keeps it visible even if its ' . 'quantity later returns to 0. "Always" shows the item from ' . 'the beginning of the game. "Never" keeps the item hidden ' . 'from the player inventory while still allowing gameplay ' . 'conditions and effects to use it.' ] ] ], 'character' => [ 'label' => 'Character Settings', 'help' => 'These are required Character properties used by Wayfarer. ' . 'They cannot be removed or renamed.', 'traits' => [ 'party_member' => [ 'label' => 'Starts in Party', 'type' => 'binary', 'default' => 0, 'required' => true, 'help' => 'Determines whether this character begins the game ' . 'as a member of the player party. No stores 0; ' . 'Yes stores 1. Storylets can later change this ' . 'state to add or remove the character from the party.' ] ] ] ] ] ], 'storylets' => [ 'label' => 'Storylets', 'singular' => 'Storylet', 'table' => 'wf_storylets', 'list_field' => 'name', /* * Permanent deletion is available from the edit screen. */ 'delete' => [ 'enabled' => true ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'trigger_type' => [ 'label' => 'Trigger Type', 'type' => 'select', 'required' => true, 'default' => 'manual', 'options' => [ 'manual' => 'Manual', 'automatic' => 'Automatic' ] ], 'max_occurrences' => [ 'label' => 'Maximum Occurrences', 'type' => 'number', 'min' => 1, 'help' => 'Leave blank for unlimited occurrences.' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ] ] ]; ================================================================================ FILE: update-backups/v56-20260925-194709/includes/builder/editor.php ================================================================================ $groupValues) { if (!is_array($groupValues)) { continue; } foreach ($groupValues as $traitName => $traitValue) { if (!isset($storedSystemTraitValues[$groupName][$traitName])) { $storedSystemTraitValues[$groupName][$traitName] = $traitValue; } } } } } $renderSystemTraits = function () use ( $record, $fields, $systemTraitsSelector, $systemTraitGroups, $storedSystemTraitValues, $storedSystemTraitMaxValues ) { if (!$systemTraitsSelector || empty($systemTraitGroups)) { return; } $selectedGroup = $record[$systemTraitsSelector] ?? ($fields[$systemTraitsSelector]['default'] ?? ''); ?>
$groupDefinition): ?>
>

$traitDefinition): ?>

>

$relationship): ?> $field): ?>

Media

Optional. Reuse an existing asset from this game's Media Library, upload a new asset, or leave the slot empty.

$mediaDefinition): ?>

Maximum upload: .

Current: ()



No existing assets have been uploaded to this game yet.

$field): ?>


Traits

Traits are additional values associated with this that can change during play.


Danger Zone

Permanently delete this . This cannot be undone.

================================================================================ FILE: update-backups/v56-20260925-194709/includes/builder/relationship-field.php ================================================================================

================================================================================ FILE: update-backups/v56-20260925-194709/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } $allowedTargets = gameplayTickEffectAllowedTargets( $gameConfig['traits'] ?? [], $regionTraits, 'region', ($record['navigation_type'] ?? '') === 'linear' ); $allowedConditionTargets = gameplayConditionAllowedTargets( $gameConfig['traits'] ?? [], $regionTraits, 'region', 'Region' ); return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayConditionAllowedTargets( $gameConfig['traits'] ?? [], $regionConfig['traits'] ?? [], 'region', 'Region' ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayEffectAllowedTargets( $gameConfig['traits'] ?? [], $regionTraits, 'region', false ), $postedEffects, gameplayConditionAllowedTargets( $gameConfig['traits'] ?? [], $regionTraits, 'region', 'Region' ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayTickEffectAllowedTargets( $gameConfig['traits'] ?? [], $regionTraits, 'region', $allowLinearNavigation ); $allowedConditionTargets = gameplayConditionAllowedTargets( $gameConfig['traits'] ?? [], $regionTraits, 'region', 'Region' ); return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $values[] = $record[$fieldName]; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v56-20260925-202033/admin/builder.php ================================================================================ prepare(" SELECT id, name, config FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; /* WAYFARER_STORYLET_BUILDER_V56 * A + New Choice Storylet link can preselect its parent and choose a sensible * sibling order without making parent_storylet_id part of the generic request. */ if ( $type === 'storylets' && !$id && !empty($_GET['parent_storylet_id']) ) { $requestedParentId = (int)$_GET['parent_storylet_id']; $parentStmt = $pdo->prepare( " SELECT id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $parentStmt->execute([ $requestedParentId, $gameId, ]); if ($parentStmt->fetchColumn()) { $record['parent_storylet_id'] = $requestedParentId; $orderStmt = $pdo->prepare( " SELECT COALESCE(MAX(choice_sort_order), 0) FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? " ); $orderStmt->execute([ $gameId, $requestedParentId, ]); $record['choice_sort_order'] = (int)$orderStmt->fetchColumn() + 10; } } // ================================================================ // SAVE / DELETE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { /* * Media Library associations are applied only after the authored * object has saved successfully and therefore has a stable ID. */ $savedRecordId = (int)$saveResult['id']; $mediaResult = applyBuilderMediaSelections( $pdo, $gameId, $definition['table'], $savedRecordId, $_POST, $_FILES ); if (!$mediaResult['saved']) { /* * The authored object itself has already saved. Keep the * editor on that record and report the media failure * accurately rather than pretending the object save failed. */ $id = $savedRecordId; $record['id'] = $savedRecordId; $error = 'Record saved, but media was not changed: ' . $mediaResult['error']; } else { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) . '&id=' . (int)$saveResult['id'] . '&tab=' . rawurlencode( (string)($_POST['active_tab'] ?? 'basics') ) ); exit; } } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder

================================================================================ FILE: update-backups/v56-20260925-202033/admin/games/edit.php ================================================================================ null, 'name' => '', 'slug' => '', 'description' => '', 'instructions' => '', 'player_slots' => 0, 'config' => null, 'status' => 'draft' ]; $traits = []; $playerTraits = []; $config = []; /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ $gameTickEffects = []; $gameplayTickEffects = []; $startingChoices = []; $startingChoicesEditor = []; $startingChoiceItemTargets = []; /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 */ $gameEndings = []; $gameEndingsEditor = []; $error = ''; // ================================================================ // LOAD EXISTING GAME // ================================================================ if (isset($_GET['id'])) { $stmt = $pdo->prepare(" SELECT * FROM wf_games WHERE id = ? "); $stmt->execute([(int)$_GET['id']]); $existingGame = $stmt->fetch(); if (!$existingGame) { die('Game not found.'); } $game = array_merge($game, $existingGame); if (!empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } if ( isset($config['traits']) && is_array($config['traits']) ) { $traits = $config['traits']; } /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ if ( isset($config['player_traits']) && is_array($config['player_traits']) ) { $playerTraits = array_values( $config['player_traits'] ); } /* WAYFARER_LOAD_GAME_TICK_EFFECTS_V33 */ if ( isset($config['tick_effects']) && is_array($config['tick_effects']) ) { $gameTickEffects = array_values( $config['tick_effects'] ); } if ( isset($config['setup']['choices']) && is_array($config['setup']['choices']) ) { $startingChoices = array_values( $config['setup']['choices'] ); } /* WAYFARER_GAME_ENDINGS_V45 */ if ( isset($config['endings']) && is_array($config['endings']) ) { $gameEndings = array_values( $config['endings'] ); } } $gameplayTickEffects = $gameTickEffects; $startingChoicesEditor = $startingChoices; $gameEndingsEditor = $gameEndings; // ================================================================ // SAVE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $game['id'] = !empty($_POST['id']) ? (int)$_POST['id'] : null; $game['name'] = trim($_POST['name'] ?? ''); $game['slug'] = trim($_POST['slug'] ?? ''); $game['description'] = trim($_POST['description'] ?? ''); $game['instructions'] = trim($_POST['instructions'] ?? ''); $game['player_slots'] = max(0, (int)($_POST['player_slots'] ?? 0)); $game['status'] = $_POST['status'] ?? 'draft'; // ------------------------------------------------------------ // BUILD GAME + PLAYER TRAIT DEFINITIONS // WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 // ------------------------------------------------------------ $traitResult = buildTraitsFromPost( $_POST['traits'] ?? [], ['status'] ); $traits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } $playerTraitResult = buildTraitsFromPost( $_POST['player_traits'] ?? [], ['status'] ); $playerTraits = $playerTraitResult['traits']; if ( $error === '' && $playerTraitResult['error'] !== '' ) { $error = 'Player Traits: ' . $playerTraitResult['error']; } /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 */ $startingChoiceItemTargets = wayfarerStartingChoiceItemQuantityTargets( $pdo, (int)($game['id'] ?? 0) ); /* WAYFARER_BUILD_STARTING_CHOICES_V36 */ /* WAYFARER_STARTING_CHOICES_CANONICAL_EMPTY_V36_1 */ $startingChoicesSubmitted = isset($_POST['setup_choices_present']) || array_key_exists( 'setup_choices', $_POST ); $postedStartingChoices = isset($_POST['setup_choices']) && is_array($_POST['setup_choices']) ? array_values( $_POST['setup_choices'] ) : []; $startingChoicesEditor = $postedStartingChoices; if ($error === '') { $startingChoiceResult = wayfarerBuildStartingChoicesFromPost( $postedStartingChoices, $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ), $startingChoiceItemTargets ); if ( $startingChoiceResult['error'] !== '' ) { $error = $startingChoiceResult['error']; } else { $startingChoices = $startingChoiceResult['choices']; $startingChoicesEditor = $startingChoices; } } /* WAYFARER_BUILD_GAME_ENDINGS_V45 */ $postedGameEndings = isset($_POST['endings']) && is_array($_POST['endings']) ? array_values( $_POST['endings'] ) : []; $gameEndingsEditor = $postedGameEndings; if ($error === '') { $endingResult = wayfarerBuildGameEndingsFromPost( $pdo, (int)($game['id'] ?? 0), $postedGameEndings, $traits ); if ( $endingResult['error'] !== '' ) { $error = $endingResult['error']; } else { $gameEndings = $endingResult['endings']; $gameEndingsEditor = $gameEndings; } } /* WAYFARER_BUILD_GAME_TICK_EFFECTS_V33 */ $postedGameTickEffects = isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ? array_values( $_POST['tick_effects'] ) : []; $gameplayTickEffects = $postedGameTickEffects; if ($error === '') { $gameTickEffectResult = gameplayBuildTickEffectsFromPost( gameplayTickEffectAllowedTargets( $traits ), $postedGameTickEffects, /* WAYFARER_GAME_WIDE_CONDITIONS_V46 */ gameplayConditionGameWideTargets( $pdo, (int)($game['id'] ?? 0), $traits ) ); if ( $gameTickEffectResult['error'] !== '' ) { $error = $gameTickEffectResult['error']; } else { $gameTickEffects = $gameTickEffectResult['effects']; $gameplayTickEffects = $gameTickEffects; } } // ------------------------------------------------------------ // VALIDATE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['name'] === '') { $error = 'Game name is required.'; } elseif ($game['slug'] === '') { $error = 'Slug is required.'; } } // ------------------------------------------------------------ // BUILD CONFIG JSON // ------------------------------------------------------------ if ($error === '') { /* * Preserve any other configuration already stored for the game. * Traits own only the "traits" section of config. */ if (empty($config) && !empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } $startingRegionId = !empty($_POST['starting_region_id']) ? (int)$_POST['starting_region_id'] : null; if ($startingRegionId !== null) { $config['starting_region_id'] = $startingRegionId; } else { unset($config['starting_region_id']); } $config['traits'] = array_values( $traits ); /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ $config['player_traits'] = array_values( $playerTraits ); /* WAYFARER_SAVE_GAME_TICK_EFFECTS_V33 */ if ($gameTickEffects) { $config['tick_effects'] = array_values( $gameTickEffects ); } else { unset( $config['tick_effects'] ); } /* WAYFARER_SAVE_STARTING_CHOICES_V36 */ /* * WAYFARER_STARTING_CHOICES_CANONICAL_EMPTY_V36_1 * * If the Setup editor participated in this submission, its current * collection is the canonical collection. In particular, deleting the * final card must persist an explicit empty array rather than relying * on the absence of setup_choices in POST. */ if ($startingChoicesSubmitted) { if ( !isset($config['setup']) || !is_array($config['setup']) ) { $config['setup'] = []; } $config['setup']['choices'] = array_values( $startingChoices ); } /* WAYFARER_SAVE_GAME_ENDINGS_V45 */ $config['endings'] = array_values( $gameEndings ); $game['config'] = json_encode( $config, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ($game['config'] === false) { $error = 'Unable to encode game configuration.'; } } // ------------------------------------------------------------ // WRITE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['id']) { $stmt = $pdo->prepare(" UPDATE wf_games SET name = ?, slug = ?, description = ?, instructions = ?, player_slots = ?, config = ?, status = ? WHERE id = ? "); $stmt->execute([ $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'], $game['id'] ]); } else { $uuid = bin2hex(random_bytes(16)); $stmt = $pdo->prepare(" INSERT INTO wf_games ( uuid, name, slug, description, instructions, player_slots, config, status ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $uuid, $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'] ]); $game['id'] = (int)$pdo->lastInsertId(); } /* * WAYFARER_STAY_ON_GAME_SETTINGS_V38 * * Successful saves remain on the same Game Settings record and reopen * the tab the author was using. */ $savedGameTab = (string)( $_POST['active_tab'] ?? 'basics' ); if ( !in_array( $savedGameTab, [ 'basics', 'setup', 'traits', 'gameplay', 'endings', ], true ) ) { $savedGameTab = 'basics'; } header( 'Location: edit.php?id=' . (int)$game['id'] . '&tab=' . rawurlencode( $savedGameTab ) ); exit; } } // ================================================================ // LOAD REGIONS FOR STARTING REGION // ================================================================ $regions = []; if (!empty($game['id'])) { $regionStmt = $pdo->prepare(" SELECT id, name FROM wf_regions WHERE game_id = ? ORDER BY name "); $regionStmt->execute([ $game['id'] ]); $regions = $regionStmt->fetchAll(); } /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_RENDER_V54 */ $startingChoiceItemTargets = wayfarerStartingChoiceItemQuantityTargets( $pdo, (int)($game['id'] ?? 0) ); ?> <?= $game['id'] ? 'Edit Game' : 'New Game' ?> - Wayfarer


Example: stormgate

================================================================================ FILE: update-backups/v56-20260925-202033/assets/css/builder.css ================================================================================ /* |-------------------------------------------------------------------------- | Wayfarer Builder |-------------------------------------------------------------------------- | Master/detail authored-object workspace. | This stylesheet is intentionally presentation-only. */ :root { --wf-border: #d8dbe2; --wf-border-strong: #b8bdc7; --wf-surface: #ffffff; --wf-surface-soft: #f6f7f9; --wf-text: #20242a; --wf-muted: #667085; --wf-accent: #2f5ea8; --wf-accent-soft: #edf3fc; --wf-danger: #a61b2b; --wf-danger-soft: #fff4f5; --wf-radius: 8px; --wf-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); } .wayfarer-builder-page { margin: 0; padding: 24px; background: #f3f4f6; color: var(--wf-text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.45; } .wayfarer-builder-page > h1, .wayfarer-builder-page > p, .wayfarer-builder-page > nav, .wayfarer-builder-page > .builder-workspace, .wayfarer-builder-page > .builder-back-link { max-width: 1400px; margin-left: auto; margin-right: auto; } .wayfarer-builder-page > h1 { margin-bottom: 2px; } .wayfarer-builder-page > h1 + p { margin-top: 0; color: var(--wf-muted); } .wayfarer-builder-page > nav { margin-top: 18px; margin-bottom: 18px; padding: 10px 14px; background: var(--wf-surface); border: 1px solid var(--wf-border); border-radius: var(--wf-radius); box-shadow: var(--wf-shadow); } .wayfarer-builder-page > nav a, .wayfarer-builder-page > nav strong { display: inline-block; margin-right: 18px; } .wayfarer-builder-page > hr { display: none; } /* ------------------------------------------------------------------ MASTER / DETAIL ------------------------------------------------------------------ */ .builder-workspace { display: grid; grid-template-columns: minmax(260px, 340px) minmax(0, 1fr); gap: 18px; align-items: start; } .builder-collection-pane, .builder-editor-pane { background: var(--wf-surface); border: 1px solid var(--wf-border); border-radius: var(--wf-radius); box-shadow: var(--wf-shadow); } .builder-collection-pane { position: sticky; top: 18px; max-height: calc(100vh - 36px); overflow: hidden; } .builder-collection-toolbar { padding: 14px; border-bottom: 1px solid var(--wf-border); background: var(--wf-surface-soft); } .builder-new-object { display: inline-block; padding: 8px 11px; border: 1px solid var(--wf-border-strong); border-radius: 6px; background: var(--wf-surface); text-decoration: none; font-weight: 600; } .builder-collection-scroll { overflow: auto; max-height: calc(100vh - 92px); padding: 14px; } .builder-collection-scroll > h2 { margin-top: 0; margin-bottom: 12px; font-size: 1.15rem; } .builder-collection-scroll ul { margin: 0; padding: 0; list-style: none; } .builder-collection-scroll li + li { margin-top: 4px; } .builder-collection-scroll li a { display: block; padding: 8px 9px; border-radius: 5px; text-decoration: none; } .builder-collection-scroll li a:hover { background: var(--wf-accent-soft); } .builder-collection-scroll table { width: 100%; border-collapse: collapse; font-size: 0.88rem; } .builder-collection-scroll th, .builder-collection-scroll td { padding: 7px 6px; border: 1px solid var(--wf-border); text-align: left; vertical-align: top; } .builder-collection-scroll th button { border: 0; padding: 0; background: transparent; font: inherit; font-weight: 600; cursor: pointer; } .builder-editor-pane { min-width: 0; padding: 20px 24px 24px; } .builder-editor-pane > h2 { margin-top: 0; margin-bottom: 14px; } /* WAYFARER_OBJECT_CONTEXT_HEADING_V34 */ .builder-object-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 8px; } .builder-object-context { color: var(--wf-muted); font-weight: 600; } .builder-object-context[hidden] { display: none; } .builder-object-context-separator { margin-right: 8px; color: var(--wf-border-strong); } /* ------------------------------------------------------------------ TABBED OBJECT EDITOR ------------------------------------------------------------------ */ .builder-object-form { margin: 0; } .builder-editor-tabs { display: flex; gap: 4px; margin: 0 0 20px; padding: 0; border-bottom: 1px solid var(--wf-border); } .builder-editor-tab { position: relative; bottom: -1px; padding: 9px 14px; border: 1px solid transparent; border-bottom: 1px solid var(--wf-border); background: transparent; color: var(--wf-muted); font: inherit; font-weight: 600; cursor: pointer; } .builder-editor-tab:hover { color: var(--wf-text); background: var(--wf-surface-soft); } .builder-editor-tab.is-active { color: var(--wf-accent); background: var(--wf-surface); border-color: var(--wf-border) var(--wf-border) var(--wf-surface); border-radius: 6px 6px 0 0; } .builder-tab-panel[hidden] { display: none !important; } .builder-tab-panel > p:first-child { margin-top: 0; } /* ------------------------------------------------------------------ EXISTING FIELD CONTROLS ------------------------------------------------------------------ */ .builder-editor-pane label { font-weight: 600; } .builder-editor-pane small { color: var(--wf-muted); font-weight: 400; } .builder-editor-pane input[type="text"], .builder-editor-pane input[type="number"], .builder-editor-pane input[type="file"], .builder-editor-pane select, .builder-editor-pane textarea { box-sizing: border-box; max-width: 100%; margin-top: 4px; padding: 7px 8px; border: 1px solid var(--wf-border-strong); border-radius: 5px; background: var(--wf-surface); color: var(--wf-text); font: inherit; } .builder-editor-pane input[type="text"], .builder-editor-pane textarea { width: min(100%, 720px); } .builder-editor-pane textarea { resize: vertical; } .builder-editor-pane fieldset { margin: 0 0 16px; padding: 14px 16px; border: 1px solid var(--wf-border); border-radius: 7px; } .builder-editor-pane legend { padding: 0 6px; } .builder-editor-pane table { max-width: 100%; border-collapse: collapse; } .builder-editor-pane th, .builder-editor-pane td { padding: 7px; } /* System settings stay visually inside Basics. */ .builder-editor-pane [data-system-trait-settings] { margin: 16px 0; padding: 14px 16px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface-soft); } .builder-editor-pane [data-system-trait-settings] h3 { margin-top: 0; } /* ------------------------------------------------------------------ SAVE / DELETE ------------------------------------------------------------------ */ .builder-save-bar { display: flex; justify-content: flex-end; margin: 22px -24px -24px; padding: 14px 24px; border-top: 1px solid var(--wf-border); background: var(--wf-surface-soft); border-radius: 0 0 var(--wf-radius) var(--wf-radius); } .builder-save-bar button { padding: 9px 16px; border: 1px solid #214b86; border-radius: 6px; background: var(--wf-accent); color: #fff; font: inherit; font-weight: 600; cursor: pointer; } .builder-editor-pane [data-builder-delete-form] { margin-top: 28px; padding: 16px; border: 1px solid #e8b8be; border-radius: 7px; background: var(--wf-danger-soft); } .builder-editor-pane [data-builder-delete-form] h3 { margin-top: 0; color: var(--wf-danger); } .builder-editor-pane [data-builder-delete-form] button { padding: 8px 12px; border: 1px solid var(--wf-danger); border-radius: 5px; background: #fff; color: var(--wf-danger) !important; font: inherit; font-weight: 600; cursor: pointer; } .builder-back-link { margin-top: 18px; } /* ------------------------------------------------------------------ RESPONSIVE ------------------------------------------------------------------ */ @media (max-width: 900px) { .wayfarer-builder-page { padding: 14px; } .builder-workspace { grid-template-columns: 1fr; } .builder-collection-pane { position: static; max-height: none; } .builder-collection-scroll { max-height: 300px; } .builder-editor-pane { padding: 18px; } .builder-save-bar { margin-left: -18px; margin-right: -18px; margin-bottom: -18px; padding-left: 18px; padding-right: 18px; } } @media (max-width: 560px) { .builder-editor-tabs { overflow-x: auto; } .builder-editor-tab { white-space: nowrap; } } /* |-------------------------------------------------------------------------- | REGION TICK EFFECTS v18 |-------------------------------------------------------------------------- */ .builder-tick-effect-row { display: grid; grid-template-columns: minmax(220px, 2fr) minmax(130px, 1fr) minmax(110px, 1fr) auto; gap: 12px; align-items: end; margin: 0 0 12px; padding: 14px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface-soft); } .builder-tick-effect-row > p { margin: 0; } .builder-tick-effect-row select, .builder-tick-effect-row input { width: 100%; box-sizing: border-box; } .builder-tick-effect-remove { align-self: end; } @media (max-width: 760px) { .builder-tick-effect-row { grid-template-columns: 1fr; } } /* |-------------------------------------------------------------------------- | TICK EFFECT RESULT BRANCHES v28 |-------------------------------------------------------------------------- | WAYFARER_TICK_EFFECT_BRANCHES_V28 */ .builder-tick-effect-row { display: block; margin: 0 0 16px; padding: 16px; } .builder-tick-effect-header { display: grid; grid-template-columns: minmax(260px, 1fr) auto; gap: 16px; align-items: end; margin-bottom: 16px; } .builder-tick-effect-target select { display: block; width: 100%; margin-top: 6px; } .builder-tick-result-list { display: grid; gap: 12px; } .builder-tick-result-branch { padding: 14px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface); } .builder-tick-result-branch.is-otherwise { background: var(--wf-surface-soft); } .builder-tick-result-heading { display: flex; justify-content: space-between; gap: 12px; align-items: center; margin-bottom: 12px; } .builder-tick-result-order { display: flex; gap: 6px; align-items: center; } .builder-tick-result-condition { margin-bottom: 10px; } .builder-tick-result-then { margin: 10px 0 8px; } .builder-tick-result-fields { display: grid; grid-template-columns: minmax(160px, 220px) minmax(120px, 180px); gap: 12px; align-items: end; } .builder-tick-result-fields select, .builder-tick-result-fields input { display: block; width: 100%; margin-top: 6px; } .builder-tick-result-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; } .builder-condition-group { padding: 10px; border: 1px solid var(--wf-border); border-radius: 6px; background: var(--wf-surface-soft); } .builder-condition-group-header, .builder-condition-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .builder-condition-children { display: grid; gap: 8px; margin: 10px 0; } .builder-condition-row { display: grid; grid-template-columns: minmax(180px, 1.4fr) minmax(180px, 1fr) minmax(120px, 1fr) auto; gap: 8px; align-items: end; } .builder-condition-row select, .builder-condition-row input, .builder-condition-group-header select { width: 100%; box-sizing: border-box; } @media (max-width: 760px) { .builder-tick-effect-header, .builder-tick-result-fields, .builder-condition-row { grid-template-columns: 1fr; } .builder-tick-result-heading { align-items: flex-start; flex-direction: column; } } /* ------------------------------------------------------------------ STARTING CHOICES WAYFARER_STARTING_CHOICES_V36 ------------------------------------------------------------------ */ .builder-starting-choices-editor { margin-top: 24px; } .builder-starting-choices-heading, .builder-starting-choice-header, .builder-starting-option-header, .builder-starting-effects-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } .builder-starting-choices-heading h3 { margin: 0 0 4px; } .builder-starting-choices-heading p { margin: 0; } .builder-starting-choice { margin-top: 16px; padding: 16px; border: 1px solid var(--wf-border); border-radius: var(--wf-radius); background: var(--wf-surface-soft); } .builder-starting-choice-fields { display: grid; grid-template-columns: minmax(0, 1fr) 140px; gap: 12px; margin-top: 14px; } .builder-starting-choice-fields label, .builder-starting-option-fields label, .builder-starting-effect label { display: grid; gap: 5px; font-weight: 600; } .builder-starting-choice-fields input, .builder-starting-choice-fields select, .builder-starting-option-fields input, .builder-starting-option-fields textarea, .builder-starting-effect input, .builder-starting-effect select { width: 100%; font: inherit; } .builder-starting-options { display: grid; gap: 12px; margin: 16px 0 12px; } .builder-starting-option { padding: 14px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface); } .builder-starting-option-fields { display: grid; grid-template-columns: minmax(180px, 0.7fr) minmax(260px, 1.3fr); gap: 12px; margin-top: 12px; } .builder-starting-effects { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--wf-border); } .builder-starting-effects-heading { margin-bottom: 8px; } .builder-starting-effects-heading small { color: var(--wf-muted); } .builder-starting-effect { display: grid; grid-template-columns: minmax(180px, 1.4fr) minmax(120px, 0.7fr) minmax(100px, 0.6fr) auto; gap: 10px; align-items: end; margin-bottom: 8px; } @media (max-width: 800px) { .builder-starting-choice-fields, .builder-starting-option-fields, .builder-starting-effect { grid-template-columns: 1fr; } } /* ------------------------------------------------------------------ ONE-TIME EFFECTS WAYFARER_ONE_TIME_EFFECTS_EDITOR_V42 ------------------------------------------------------------------ */ .builder-effects-panel > hr { margin: 28px 0; border: 0; border-top: 1px solid var(--wf-border, #d8dbe2); } .builder-effects-list { display: grid; gap: 10px; margin: 14px 0; } .builder-effect-row { display: grid; grid-template-columns: minmax(220px, 1.5fr) minmax(130px, 0.7fr) minmax(110px, 0.6fr) auto; gap: 10px; align-items: end; padding: 12px; border: 1px solid var(--wf-border, #d8dbe2); border-radius: 8px; background: #ffffff; } .builder-effect-row label { display: grid; gap: 5px; } .builder-effect-row select, .builder-effect-row input { width: 100%; } .builder-effect-remove { align-self: end; } @media (max-width: 850px) { .builder-effect-row { grid-template-columns: 1fr; } .builder-effect-remove { justify-self: start; } } /* ------------------------------------------------------------------ GENERALIZED EFFECTS WAYFARER_GENERALIZED_EFFECT_EDITOR_V43 ------------------------------------------------------------------ */ /* * v42 displayed an Effect as one horizontal operation row. v43 Effects own * ordered result branches, so use the same card language as Tick Effects. */ .builder-effect-row { display: block; grid-template-columns: none; padding: 14px; } .builder-effect-row .builder-tick-effect-header { margin-bottom: 12px; } .builder-effect-value-fields { display: grid; grid-template-columns: minmax(120px, 0.75fr) minmax(120px, 0.75fr) minmax(120px, 0.75fr); gap: 10px; align-items: end; } .builder-effect-random-range { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .builder-effect-random-range > label { display: grid; gap: 5px; } [data-effect-fixed-wrap][hidden], [data-effect-random-wrap][hidden], [data-effect-value-mode-wrap][hidden] { display: none !important; } @media (max-width: 850px) { .builder-effect-value-fields { grid-template-columns: 1fr; } .builder-effect-random-range { grid-template-columns: 1fr; } } /* ------------------------------------------------------------------ STORYLET CHOICES WAYFARER_STORYLET_BUILDER_V56 ------------------------------------------------------------------ */ .builder-storylet-choices { max-width: 100%; overflow-x: auto; } .builder-storylet-choices table { width: 100%; border-collapse: collapse; } .builder-storylet-choices th, .builder-storylet-choices td { padding: 9px 10px; border: 1px solid var(--wf-border); text-align: left; vertical-align: top; } .builder-storylet-choices th { background: var(--wf-surface-soft); } ================================================================================ FILE: update-backups/v56-20260925-202033/assets/js/builder.js ================================================================================ const wayfarerConditionData = window.wayfarerConditionData || {}; function updateConditionalConfigFields() { document .querySelectorAll('[data-config-field]') .forEach(function (container) { const relationshipName = container.dataset.conditionRelationship; const requiredValue = container.dataset.conditionEquals; if (!relationshipName) { container.style.display = ''; return; } const relationshipSelect = document.querySelector( '[data-relationship="' + relationshipName + '"]' ); if (!relationshipSelect) { container.style.display = 'none'; return; } const selectedId = relationshipSelect.value; const actualValue = wayfarerConditionData[relationshipName]?.[selectedId]; container.style.display = actualValue === requiredValue ? '' : 'none'; }); } document .querySelectorAll('[data-relationship]') .forEach(function (select) { select.addEventListener( 'change', updateConditionalConfigFields ); }); // ================================================================ // SYSTEM TRAIT GROUPS // ================================================================ function updateSystemTraitGroups(container) { const selectorField = container.dataset.selectorField; if (!selectorField) { return; } const selector = document.querySelector( '[name="' + selectorField + '"]' ); if (!selector) { return; } const selectedGroup = selector.value; container .querySelectorAll('[data-system-trait-group]') .forEach(function (group) { const active = group.dataset.systemTraitGroup === selectedGroup; group.style.display = active ? '' : 'none'; group .querySelectorAll('input, select, textarea') .forEach(function (input) { input.disabled = !active; }); }); } document .querySelectorAll('[data-system-trait-settings]') .forEach(function (container) { const selectorField = container.dataset.selectorField; const selector = selectorField ? document.querySelector( '[name="' + selectorField + '"]' ) : null; if (selector) { selector.addEventListener( 'change', function () { updateSystemTraitGroups(container); } ); } updateSystemTraitGroups(container); }); // ================================================================ // COLLECTION SORTING // ================================================================ document .querySelectorAll('[data-sort-column]') .forEach(function (button) { button.addEventListener('click', function () { const table = button.closest('[data-collection-table]'); if (!table) { return; } const tbody = table.querySelector('tbody'); const columnName = button.dataset.sortColumn; const sortType = button.dataset.sortType || 'text'; const currentDirection = button.dataset.sortDirection || 'none'; const nextDirection = currentDirection === 'asc' ? 'desc' : 'asc'; table .querySelectorAll('[data-sort-column]') .forEach(function (otherButton) { otherButton.dataset.sortDirection = 'none'; }); button.dataset.sortDirection = nextDirection; const rows = Array.from(tbody.querySelectorAll('tr')); rows.sort(function (a, b) { const aCell = a.querySelector( '[data-column="' + columnName + '"]' ); const bCell = b.querySelector( '[data-column="' + columnName + '"]' ); const aValue = aCell?.dataset.sortValue ?? ''; const bValue = bCell?.dataset.sortValue ?? ''; let comparison = 0; if (sortType === 'number') { const aNumber = aValue === '' ? Number.POSITIVE_INFINITY : Number(aValue); const bNumber = bValue === '' ? Number.POSITIVE_INFINITY : Number(bValue); comparison = aNumber - bNumber; } else { comparison = aValue.localeCompare( bValue, undefined, { numeric: true, sensitivity: 'base' } ); } return nextDirection === 'asc' ? comparison : -comparison; }); rows.forEach(function (row) { tbody.appendChild(row); }); }); }); updateConditionalConfigFields(); // ================================================================ // MEDIA LIBRARY CONTROLS // ================================================================ function updateBuilderMediaSlot(slotContainer) { const checkedAction = slotContainer.querySelector( 'input[type="radio"][name^="media_action"]:checked' ); const action = checkedAction ? checkedAction.value : 'none'; const existingSelect = slotContainer.querySelector( '[data-media-existing]' ); if (existingSelect) { existingSelect.disabled = action !== 'existing'; } slotContainer .querySelectorAll( '[data-media-upload-fields] input' ) .forEach(function (input) { input.disabled = action !== 'upload'; }); } document .querySelectorAll('[data-media-slot]') .forEach(function (slotContainer) { slotContainer .querySelectorAll( 'input[type="radio"][name^="media_action"]' ) .forEach(function (radio) { radio.addEventListener( 'change', function () { updateBuilderMediaSlot( slotContainer ); } ); }); updateBuilderMediaSlot( slotContainer ); }); // ================================================================ // AUTHORED OBJECT CONTEXT HEADING // WAYFARER_OBJECT_CONTEXT_HEADING_V34 // ================================================================ function initializeBuilderObjectContextHeading() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } const heading = editor.querySelector( '[data-builder-object-heading]' ); if (!heading) { return; } const fieldName = heading.dataset.builderObjectLabelField || 'name'; const form = editor.querySelector( 'form' ); if (!form) { return; } const field = Array .from( form.elements ) .find(function (element) { return ( element && element.name === fieldName ); }); if (!field) { return; } const context = heading.querySelector( '[data-builder-object-context]' ); const label = heading.querySelector( '[data-builder-object-context-label]' ); if (!context || !label) { return; } function refreshContextHeading() { const value = String( field.value || '' ).trim(); label.textContent = value; context.hidden = value === ''; } field.addEventListener( 'input', refreshContextHeading ); field.addEventListener( 'change', refreshContextHeading ); refreshContextHeading(); } initializeBuilderObjectContextHeading(); // ================================================================ // AUTHORED OBJECT EDITOR TABS // ================================================================ function initializeBuilderEditorTabs() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } /* * The editor component has one primary save form. The separate delete * form, when present, lives later in the editor pane and is deliberately * left outside the tab system. */ const form = Array .from(editor.children) .find(function (child) { return child.tagName === 'FORM'; }); if ( !form || form.dataset.builderTabbed === '1' ) { return; } form.dataset.builderTabbed = '1'; form.classList.add( 'builder-object-form' ); /* * WAYFARER_ACTIVE_TAB_V20 * * Persist the selected editor tab through POST/redirect/reload. */ const activeTabInput = document.createElement( 'input' ); activeTabInput.type = 'hidden'; activeTabInput.name = 'active_tab'; const requestedTab = new URLSearchParams( window.location.search ).get( 'tab' ) || 'basics'; activeTabInput.value = requestedTab; form.appendChild( activeTabInput ); const originalChildren = Array.from( form.children ); const panels = { basics: document.createElement('div'), media: document.createElement('div'), traits: document.createElement('div'), /* WAYFARER_CONDITIONS_PANEL_V45_1 */ conditions: document.createElement('div'), tick_effects: document.createElement('div'), /* WAYFARER_STORYLET_BUILDER_V56 */ choices: document.createElement('div') }; panels.basics.className = 'builder-tab-panel'; panels.media.className = 'builder-tab-panel'; panels.traits.className = 'builder-tab-panel'; panels.conditions.className = 'builder-tab-panel'; panels.choices.className = 'builder-tab-panel'; panels.basics.dataset.builderPanel = 'basics'; panels.media.dataset.builderPanel = 'media'; panels.traits.dataset.builderPanel = 'traits'; panels.conditions.dataset.builderPanel = 'conditions'; panels.choices.dataset.builderPanel = 'choices'; panels.tick_effects.className = 'builder-tab-panel'; panels.tick_effects.dataset.builderPanel = 'tick_effects'; let section = 'basics'; let saveContainer = null; let hasMedia = false; let hasTraits = false; let hasConditions = false; let hasTickEffects = false; let hasChoices = false; originalChildren.forEach( function (child) { /* * Keep hidden record identifiers directly in the form. */ if ( child.tagName === 'INPUT' && child.type === 'hidden' ) { return; } /* * Save becomes a stable footer beneath the tab panels. */ const submitButton = child.querySelector ? child.querySelector( 'button[type="submit"], input[type="submit"]' ) : null; if (submitButton) { saveContainer = child; return; } /* * Ignore the separator immediately before a tab heading. * Other HR elements are harmless but unnecessary inside the * cleaned-up editor. */ if (child.tagName === 'HR') { child.remove(); return; } if (child.tagName === 'H3') { const heading = child.textContent .trim() .toLowerCase(); if (heading === 'media') { section = 'media'; hasMedia = true; child.remove(); return; } if (heading === 'traits') { section = 'traits'; hasTraits = true; child.remove(); return; } } /* * WAYFARER_CONDITIONS_PANEL_V45_1 * * Conditions answer whether an authored object is eligible; * Effects answer what changes when it triggers. */ if ( child.hasAttribute && child.hasAttribute( 'data-builder-conditions-panel' ) ) { section = 'conditions'; hasConditions = true; } /* * WAYFARER_ONE_TIME_EFFECTS_EDITOR_V42 * * Location Effects and Tick Effects share one Effects panel. */ if ( child.hasAttribute && ( child.hasAttribute( 'data-builder-effects-panel' ) || child.hasAttribute( 'data-builder-tick-effects' ) ) ) { section = 'tick_effects'; hasTickEffects = true; } /* WAYFARER_STORYLET_CHOICES_PANEL_V56 */ if ( child.hasAttribute && child.hasAttribute( 'data-builder-choices-panel' ) ) { section = 'choices'; hasChoices = true; } /* * Config fields belong to Basics even though the old v9 Media * markup happens to precede them in the source template. */ if ( child.hasAttribute && child.hasAttribute( 'data-config-field' ) ) { section = 'basics'; } /* * A Media fieldset is definitive even if future markup changes * around the Media heading. */ if ( child.hasAttribute && child.hasAttribute( 'data-media-slot' ) ) { section = 'media'; hasMedia = true; } panels[section] .appendChild( child ); } ); /* * If Traits exist, anything moved after the Traits heading belongs there. * System traits do not use that heading and therefore stay in Basics. */ if ( panels.traits.children.length > 0 ) { hasTraits = true; } if ( panels.media.children.length > 0 ) { hasMedia = true; } if ( panels.conditions.children.length > 0 ) { hasConditions = true; } if ( panels.tick_effects.children.length > 0 ) { hasTickEffects = true; } if ( panels.choices.children.length > 0 ) { hasChoices = true; } const tabBar = document.createElement( 'div' ); tabBar.className = 'builder-editor-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const availablePanels = [ { key: 'basics', label: 'Basics', show: true }, { key: 'media', label: 'Media', show: hasMedia }, { key: 'traits', label: 'Traits', show: hasTraits }, { key: 'conditions', label: 'Conditions', show: hasConditions }, { key: 'tick_effects', label: 'Effects', show: hasTickEffects }, { key: 'choices', label: 'Choices', show: hasChoices } ].filter( function (item) { return item.show; } ); function activatePanel(panelKey) { activeTabInput.value = panelKey; tabBar .querySelectorAll( '[data-builder-tab]' ) .forEach( function (button) { const active = button.dataset.builderTab === panelKey; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); Object .entries(panels) .forEach( function (entry) { const key = entry[0]; const panel = entry[1]; panel.hidden = key !== panelKey; } ); } availablePanels.forEach( function (item) { const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'builder-editor-tab'; button.dataset.builderTab = item.key; button.setAttribute( 'role', 'tab' ); button.textContent = item.label; button.addEventListener( 'click', function () { activatePanel( item.key ); } ); tabBar.appendChild( button ); } ); /* * Insert the new structure after hidden inputs so all form fields remain * part of the same form and submit exactly as before. */ form.appendChild( tabBar ); availablePanels.forEach( function (item) { form.appendChild( panels[item.key] ); } ); if (saveContainer) { saveContainer.classList.add( 'builder-save-bar' ); form.appendChild( saveContainer ); } const initialPanelKey = availablePanels.some( function (item) { return item.key === requestedTab; } ) ? requestedTab : 'basics'; activatePanel( initialPanelKey ); } initializeBuilderEditorTabs(); // ================================================================ // SHARED GAMEPLAY TICK EFFECTS CONTROLS v25 // WAYFARER_GAMEPLAY_MODULAR_V25 // ================================================================ function initializeTickEffects() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } const wrapper = editor.querySelector( '[data-builder-tick-effects]' ); if (!wrapper) { return; } if ( wrapper.dataset.tickEffectsReady === '1' ) { return; } wrapper.dataset.tickEffectsReady = '1'; // ------------------------------------------------------------ // ROW ADD / REMOVE // ------------------------------------------------------------ const list = wrapper.querySelector( '[data-tick-effects-list]' ); const addButton = wrapper.querySelector( '[data-add-tick-effect]' ); const template = wrapper.querySelector( '[data-tick-effect-template]' ); let nextIndex = 0; wrapper .querySelectorAll( '[data-tick-effect-row]' ) .forEach( function (row) { const index = Number( row.dataset.tickIndex ); if ( Number.isFinite(index) && index >= nextIndex ) { nextIndex = index + 1; } } ); function updateLinearTargetAvailability() { const navigationSelect = editor.querySelector( 'select[name="navigation_type"]' ); const navigationType = navigationSelect ? navigationSelect.value : wrapper.dataset.navigationType; const linear = navigationType === 'linear'; wrapper .querySelectorAll( '[data-linear-navigation-target]' ) .forEach( function (option) { option.disabled = !linear; } ); if (!linear) { wrapper .querySelectorAll( '[data-tick-effect-target]' ) .forEach( function (select) { if ( select.value === 'navigation:position' ) { select.value = ''; } } ); } } if ( addButton && list && template ) { addButton.addEventListener( 'click', function () { const html = template.innerHTML.replace( /__INDEX__/g, String( nextIndex++ ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const row = temporary.firstElementChild; if (!row) { return; } list.appendChild( row ); updateLinearTargetAvailability(); } ); } wrapper.addEventListener( 'click', function (event) { const removeButton = event.target.closest( '[data-remove-tick-effect]' ); if (!removeButton) { return; } const row = removeButton.closest( '[data-tick-effect-row]' ); if (row) { row.remove(); } } ); const navigationSelect = editor.querySelector( 'select[name="navigation_type"]' ); if (navigationSelect) { navigationSelect.addEventListener( 'change', updateLinearTargetAvailability ); } updateLinearTargetAvailability(); /* * WAYFARER_EMBEDDED_TICK_EFFECTS_V33 * * Game Settings already owns a Gameplay tab. In embedded mode we keep * this shared editor exactly where PHP rendered it while retaining the * normal Tick Effect, condition, and result-branch controls. */ if ( wrapper.hasAttribute( 'data-tick-effects-embedded' ) ) { return; } // ------------------------------------------------------------ // MOVE THE SECTION INTO ITS OWN BUILDER TAB // ------------------------------------------------------------ const form = wrapper.closest( 'form' ); if (!form) { return; } const tabBar = form.querySelector( '.builder-editor-tabs' ); if (!tabBar) { /* * Builder without the v10 tab layout: * leave the Tick Effects section inline. */ return; } if ( form.querySelector( '[data-builder-panel="tick_effects"]' ) ) { return; } const panel = document.createElement( 'div' ); panel.className = 'builder-tab-panel'; panel.dataset.builderPanel = 'tick_effects'; panel.hidden = true; panel.appendChild( wrapper ); const saveBar = form.querySelector( '.builder-save-bar' ); if (saveBar) { form.insertBefore( panel, saveBar ); } else { form.appendChild( panel ); } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'builder-editor-tab'; button.dataset.builderTab = 'tick_effects'; button.setAttribute( 'role', 'tab' ); button.setAttribute( 'aria-selected', 'false' ); /* WAYFARER_GAMEPLAY_TAB_LABEL_V25_1 */ button.textContent = 'Effects'; tabBar.appendChild( button ); function activateTickEffectsTab() { tabBar .querySelectorAll( '[data-builder-tab]' ) .forEach( function (tabButton) { const active = tabButton === button; tabButton.classList.toggle( 'is-active', active ); tabButton.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); form .querySelectorAll( '[data-builder-panel]' ) .forEach( function (tabPanel) { tabPanel.hidden = tabPanel.dataset.builderPanel !== 'tick_effects'; } ); } button.addEventListener( 'click', activateTickEffectsTab ); /* * The original tab controller does not know about the panel added * afterward, so explicitly hide our panel whenever an original tab * is clicked. */ tabBar .querySelectorAll( '[data-builder-tab]:not([data-builder-tab="tick_effects"])' ) .forEach( function (originalButton) { originalButton.addEventListener( 'click', function () { panel.hidden = true; button.classList.remove( 'is-active' ); button.setAttribute( 'aria-selected', 'false' ); } ); } ); } // ================================================================ // SHARED ONE-TIME EFFECTS CONTROLS // WAYFARER_ONE_TIME_EFFECTS_EDITOR_V42 // WAYFARER_GENERALIZED_EFFECT_EDITOR_V43 // ================================================================ function wayfarerEffectConditionTargets( wrapper ) { const targetJson = wrapper.querySelector( '[data-effect-condition-targets-json]' ); if (!targetJson) { return {}; } try { return JSON.parse( targetJson.textContent || '{}' ); } catch (error) { return {}; } } function wayfarerEffectBuildConditionEditor( host, name, targets ) { const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( name, targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } function wayfarerEffectUpdateResultFields( branch ) { const operation = branch.querySelector( '[data-effect-branch-operation]' ); const modeWrap = branch.querySelector( '[data-effect-value-mode-wrap]' ); const mode = branch.querySelector( '[data-effect-value-mode]' ); const fixedWrap = branch.querySelector( '[data-effect-fixed-wrap]' ); const randomWrap = branch.querySelector( '[data-effect-random-wrap]' ); if (!operation) { return; } const noChange = operation.value === 'none'; if (modeWrap) { modeWrap.hidden = noChange; modeWrap .querySelectorAll( 'select, input' ) .forEach( function (field) { field.disabled = noChange; } ); } const valueMode = mode ? mode.value : 'fixed'; if (fixedWrap) { fixedWrap.hidden = noChange || valueMode !== 'fixed'; fixedWrap .querySelectorAll( 'input' ) .forEach( function (field) { field.disabled = noChange || valueMode !== 'fixed'; } ); } if (randomWrap) { randomWrap.hidden = noChange || valueMode !== 'random'; randomWrap .querySelectorAll( 'input' ) .forEach( function (field) { field.disabled = noChange || valueMode !== 'random'; } ); } } function wayfarerEffectReindexBranches( effectRow ) { const effectIndex = effectRow.dataset.effectIndex; const list = effectRow.querySelector( '[data-effect-branch-list]' ); if ( effectIndex === undefined || !list ) { return; } const branches = Array .from( list.children ) .filter( function (child) { return ( child.matches && child.matches( '[data-effect-branch]' ) ); } ); branches.forEach( function ( branch, branchIndex ) { branch.dataset.branchIndex = String( branchIndex ); branch .querySelectorAll( '[name]' ) .forEach( function (field) { field.name = field.name.replace( /effects\[\d+\]\[branches\]\[\d+\]/, 'effects[' + effectIndex + '][branches][' + branchIndex + ']' ); } ); } ); } function wayfarerEffectRefreshBranchControls( effectRow ) { const list = effectRow.querySelector( '[data-effect-branch-list]' ); if (!list) { return; } const branches = Array .from( list.children ) .filter( function (child) { return ( child.matches && child.matches( '[data-effect-branch]' ) ); } ); const conditionals = branches.filter( function (branch) { return ( branch.dataset.branchKind === 'conditional' ); } ); conditionals.forEach( function ( branch, index ) { const up = branch.querySelector( '[data-move-effect-branch-up]' ); const down = branch.querySelector( '[data-move-effect-branch-down]' ); if (up) { up.disabled = index === 0; } if (down) { down.disabled = index === conditionals.length - 1; } } ); const hasOtherwise = branches.some( function (branch) { return ( branch.dataset.branchKind === 'otherwise' ); } ); const addOtherwise = effectRow.querySelector( '[data-add-effect-otherwise]' ); if (addOtherwise) { addOtherwise.disabled = hasOtherwise; } branches.forEach( wayfarerEffectUpdateResultFields ); wayfarerEffectReindexBranches( effectRow ); } function initializeEffects() { document .querySelectorAll( '[data-builder-effects]' ) .forEach( function (wrapper) { if ( wrapper.dataset.effectsReady === '1' ) { return; } wrapper.dataset.effectsReady = '1'; const list = wrapper.querySelector( '[data-effects-list]' ); const addButton = wrapper.querySelector( '[data-add-effect]' ); const effectTemplate = wrapper.querySelector( '[data-effect-template]' ); const conditionalTemplate = wrapper.querySelector( '[data-effect-conditional-template]' ); const otherwiseTemplate = wrapper.querySelector( '[data-effect-otherwise-template]' ); const conditionTargets = wayfarerEffectConditionTargets( wrapper ); let nextIndex = 0; wrapper .querySelectorAll( '[data-effect-row]' ) .forEach( function (row) { const index = Number( row.dataset.effectIndex ); if ( Number.isFinite( index ) && index >= nextIndex ) { nextIndex = index + 1; } wayfarerEffectRefreshBranchControls( row ); } ); if ( addButton && list && effectTemplate ) { addButton.addEventListener( 'click', function () { const effectIndex = nextIndex++; const html = effectTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', String( effectIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const row = temporary.firstElementChild; if (!row) { return; } list.appendChild( row ); wayfarerEffectRefreshBranchControls( row ); } ); } wrapper.addEventListener( 'change', function (event) { const operation = event.target.closest( '[data-effect-branch-operation]' ); const valueMode = event.target.closest( '[data-effect-value-mode]' ); if ( !operation && !valueMode ) { return; } const branch = event.target.closest( '[data-effect-branch]' ); if (branch) { wayfarerEffectUpdateResultFields( branch ); } } ); wrapper.addEventListener( 'click', function (event) { const effectRow = event.target.closest( '[data-effect-row]' ); const removeEffect = event.target.closest( '[data-remove-effect]' ); if (removeEffect) { if (!effectRow) { return; } if ( !window.confirm( 'Remove this Effect?' ) ) { return; } effectRow.remove(); return; } if (!effectRow) { return; } const addConditional = event.target.closest( '[data-add-effect-conditional]' ); if (addConditional) { if (!conditionalTemplate) { return; } const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if (!branchList) { return; } const effectIndex = effectRow.dataset.effectIndex; const branchIndex = branchList .querySelectorAll( ':scope > [data-effect-branch]' ) .length; const html = conditionalTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } const otherwise = Array .from( branchList.children ) .find( function (child) { return ( child.dataset && child.dataset.branchKind === 'otherwise' ); } ); if (otherwise) { branchList.insertBefore( branch, otherwise ); } else { branchList.appendChild( branch ); } wayfarerEffectReindexBranches( effectRow ); const newIndex = branch.dataset.branchIndex; const host = branch.querySelector( '[data-new-effect-condition-editor]' ); if (host) { wayfarerEffectBuildConditionEditor( host, 'effects[' + effectIndex + '][branches][' + newIndex + '][conditions]', conditionTargets ); } wayfarerEffectRefreshBranchControls( effectRow ); return; } const addOtherwise = event.target.closest( '[data-add-effect-otherwise]' ); if (addOtherwise) { if (!otherwiseTemplate) { return; } const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if ( !branchList || branchList.querySelector( '[data-branch-kind="otherwise"]' ) ) { return; } const effectIndex = effectRow.dataset.effectIndex; const branchIndex = branchList .querySelectorAll( ':scope > [data-effect-branch]' ) .length; const html = otherwiseTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } branchList.appendChild( branch ); wayfarerEffectRefreshBranchControls( effectRow ); return; } const removeBranch = event.target.closest( '[data-remove-effect-branch]' ); if (removeBranch) { const branch = removeBranch.closest( '[data-effect-branch]' ); if (branch) { branch.remove(); wayfarerEffectRefreshBranchControls( effectRow ); } return; } const moveUp = event.target.closest( '[data-move-effect-branch-up]' ); const moveDown = event.target.closest( '[data-move-effect-branch-down]' ); if ( !moveUp && !moveDown ) { return; } const branch = event.target.closest( '[data-effect-branch]' ); const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if ( !branch || !branchList || branch.dataset.branchKind !== 'conditional' ) { return; } const conditionals = Array .from( branchList.children ) .filter( function (child) { return ( child.dataset && child.dataset.branchKind === 'conditional' ); } ); const index = conditionals.indexOf( branch ); if ( moveUp && index > 0 ) { branchList.insertBefore( branch, conditionals[ index - 1 ] ); } if ( moveDown && index >= 0 && index < conditionals.length - 1 ) { const next = conditionals[ index + 1 ]; branchList.insertBefore( next, branch ); } wayfarerEffectRefreshBranchControls( effectRow ); } ); } ); } initializeEffects(); initializeTickEffects();initializeTickEffects(); // ================================================================ // REUSABLE CONDITION EDITOR v27 // WAYFARER_CONDITION_EDITOR_V27 // ================================================================ function wayfarerConditionEscape(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function wayfarerConditionTargetOptions( targets ) { const groups = {}; Object .entries( targets || {} ) .forEach( function (entry) { const key = entry[0]; const target = entry[1] || {}; const group = target.group || 'Targets'; if (!groups[group]) { groups[group] = []; } groups[group].push({ key: key, label: target.label || key }); } ); let html = ''; Object .entries(groups) .forEach( function (entry) { html += ''; entry[1] .forEach( function (target) { html += ''; } ); html += ''; } ); return html; } function wayfarerConditionNextChildIndex( group ) { let maxIndex = -1; const children = Array .from( group.children ) .find( function (child) { return child.hasAttribute( 'data-condition-children' ); } ); if (children) { Array .from( children.children ) .forEach( function (node) { let index = Number( node.dataset.conditionChildIndex ); if (!Number.isFinite(index)) { const namedInput = node.querySelector( '[name]' ); const match = namedInput ? namedInput.name.match( /\[children\]\[(\d+)\]/ ) : null; index = match ? Number(match[1]) : -1; } if ( Number.isFinite(index) && index > maxIndex ) { maxIndex = index; } } ); } return maxIndex + 1; } function wayfarerConditionCreateCondition( name, targets, childIndex ) { const row = document.createElement( 'div' ); row.className = 'builder-condition-row'; row.dataset.conditionNode = '1'; row.dataset.conditionType = 'condition'; row.dataset.conditionChildIndex = String(childIndex); row.innerHTML = '' + '' + '' + '' + ''; return row; } function wayfarerConditionCreateGroup( name, targets, childIndex, isRoot ) { const group = document.createElement( 'div' ); group.className = 'builder-condition-group' + ( isRoot ? ' builder-condition-group-root' : '' ); group.dataset.conditionNode = '1'; group.dataset.conditionType = 'group'; if ( childIndex !== null && childIndex !== undefined ) { group.dataset.conditionChildIndex = String(childIndex); } group.innerHTML = '' + '
' + '' + ' of the following' + ( isRoot ? '' : '' ) + '
' + '
' + '
' + ' ' + '' + '
'; return group; } function wayfarerConditionInitializeEditor( editor, targets ) { if ( !editor || editor.dataset.conditionEditorReady === '1' ) { return; } editor.dataset.conditionEditorReady = '1'; editor.addEventListener( 'click', function (event) { const removeButton = event.target.closest( '[data-remove-condition-node]' ); if (removeButton) { const node = removeButton.closest( '[data-condition-node]' ); if (node) { node.remove(); } return; } const addCondition = event.target.closest( '[data-add-condition]' ); const addGroup = event.target.closest( '[data-add-condition-group]' ); if ( !addCondition && !addGroup ) { return; } const group = event.target.closest( '[data-condition-type="group"]' ); if (!group) { return; } const children = Array .from( group.children ) .find( function (child) { return child.hasAttribute( 'data-condition-children' ); } ); if (!children) { return; } const groupTypeInput = Array .from( group.children ) .find( function (child) { return ( child.tagName === 'INPUT' && child.type === 'hidden' && child.name.endsWith( '[type]' ) ); } ); if (!groupTypeInput) { return; } const groupName = groupTypeInput.name.replace( /\[type\]$/, '' ); const nextIndex = wayfarerConditionNextChildIndex( group ); const childName = groupName + '[children][' + nextIndex + ']'; const newNode = addGroup ? wayfarerConditionCreateGroup( childName, targets, nextIndex, false ) : wayfarerConditionCreateCondition( childName, targets, nextIndex ); children.appendChild( newNode ); } ); } function initializeConditionEditors() { /* * WAYFARER_GENERIC_CONDITION_INIT_V45_2 * * The Condition Editor is shared infrastructure. It must work anywhere it * is rendered, not only when nested inside Tick Effects. * * Server-rendered editors carry their own allowed-target JSON. Dynamic * Tick Effect / Effect editors continue to initialize themselves through * their existing creation paths. */ document .querySelectorAll( '[data-condition-editor]' ) .forEach( function (editor) { let targets = {}; const targetJson = editor.querySelector( '[data-condition-editor-targets-json]' ); if (targetJson) { try { targets = JSON.parse( targetJson.textContent || '{}' ); } catch (error) { targets = {}; } } wayfarerConditionInitializeEditor( editor, targets ); } ); /* * Tick Effects retain their observer because newly-created Tick Effect * rows are added after page load and are not server-rendered editors. */ document .querySelectorAll( '[data-builder-tick-effects]' ) .forEach( function (wrapper) { let targets = {}; const targetJson = wrapper.querySelector( '[data-condition-targets-json]' ); if (targetJson) { try { targets = JSON.parse( targetJson.textContent || '{}' ); } catch (error) { targets = {}; } } wrapper .querySelectorAll( '[data-condition-editor]' ) .forEach( function (editor) { wayfarerConditionInitializeEditor( editor, targets ); } ); /* * New Tick Effect rows are cloned from a template after page * load, so convert their placeholder into a fresh root group. */ const list = wrapper.querySelector( '[data-tick-effects-list]' ); if ( list && !list.dataset.conditionObserverReady ) { list.dataset.conditionObserverReady = '1'; const observer = new MutationObserver( function (mutations) { mutations.forEach( function (mutation) { mutation.addedNodes .forEach( function (addedNode) { if ( !addedNode.querySelectorAll ) { return; } const row = addedNode.matches && addedNode.matches( '[data-tick-effect-row]' ) ? addedNode : addedNode.querySelector( '[data-tick-effect-row]' ); if (!row) { return; } const host = row.querySelector( '[data-new-condition-editor]' ); if (!host) { return; } const index = row.dataset.tickIndex; const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( 'tick_effects[' + index + '][conditions]', targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } ); } ); } ); observer.observe( list, { childList: true, subtree: true } ); } } ); } initializeConditionEditors(); // ================================================================ // TICK EFFECT RESULT BRANCHES v28 // WAYFARER_TICK_EFFECT_BRANCHES_V28 // ================================================================ function wayfarerTickEffectConditionTargets( wrapper ) { const targetJson = wrapper.querySelector( '[data-condition-targets-json]' ); if (!targetJson) { return {}; } try { return JSON.parse( targetJson.textContent || '{}' ); } catch (error) { return {}; } } function wayfarerTickEffectUpdateBranchValue( branch ) { const operation = branch.querySelector( '[data-tick-branch-operation]' ); const valueWrap = branch.querySelector( '[data-tick-branch-value-wrap]' ); if ( !operation || !valueWrap ) { return; } const input = valueWrap.querySelector( 'input' ); const noChange = operation.value === 'none'; valueWrap.hidden = noChange; if (input) { input.disabled = noChange; } } function wayfarerTickEffectReindexBranches( effectRow ) { const effectIndex = effectRow.dataset.tickIndex; const list = effectRow.querySelector( '[data-tick-branch-list]' ); if ( effectIndex === undefined || !list ) { return; } const branches = Array.from( list.children ).filter( function (child) { return ( child.matches && child.matches( '[data-tick-effect-branch]' ) ); } ); branches.forEach( function ( branch, branchIndex ) { branch.dataset.branchIndex = String( branchIndex ); branch .querySelectorAll( '[name]' ) .forEach( function (field) { field.name = field.name.replace( /tick_effects\[\d+\]\[branches\]\[\d+\]/, 'tick_effects[' + effectIndex + '][branches][' + branchIndex + ']' ); } ); } ); } function wayfarerTickEffectRefreshBranchControls( effectRow ) { const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } const branches = Array.from( list.children ).filter( function (child) { return ( child.matches && child.matches( '[data-tick-effect-branch]' ) ); } ); const conditionalBranches = branches.filter( function (branch) { return ( branch.dataset.branchKind === 'conditional' ); } ); conditionalBranches.forEach( function ( branch, index ) { const up = branch.querySelector( '[data-move-tick-branch-up]' ); const down = branch.querySelector( '[data-move-tick-branch-down]' ); if (up) { up.disabled = index === 0; } if (down) { down.disabled = index === conditionalBranches.length - 1; } } ); const hasOtherwise = branches.some( function (branch) { return ( branch.dataset.branchKind === 'otherwise' ); } ); const addOtherwise = effectRow.querySelector( '[data-add-tick-otherwise-result]' ); if (addOtherwise) { addOtherwise.disabled = hasOtherwise; } branches.forEach( wayfarerTickEffectUpdateBranchValue ); wayfarerTickEffectReindexBranches( effectRow ); } function wayfarerTickEffectBuildConditionEditor( host, name, targets ) { const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( name, targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } function initializeTickEffectResultBranches() { document .querySelectorAll( '[data-builder-tick-effects]' ) .forEach( function (wrapper) { if ( wrapper.dataset.tickBranchesReady === '1' ) { return; } wrapper.dataset.tickBranchesReady = '1'; const conditionalTemplate = wrapper.querySelector( '[data-tick-conditional-branch-template]' ); const otherwiseTemplate = wrapper.querySelector( '[data-tick-otherwise-branch-template]' ); const conditionTargets = wayfarerTickEffectConditionTargets( wrapper ); wrapper .querySelectorAll( '[data-tick-effect-row]' ) .forEach( wayfarerTickEffectRefreshBranchControls ); wrapper.addEventListener( 'change', function (event) { const operation = event.target.closest( '[data-tick-branch-operation]' ); if (!operation) { return; } const branch = operation.closest( '[data-tick-effect-branch]' ); if (branch) { wayfarerTickEffectUpdateBranchValue( branch ); } } ); wrapper.addEventListener( 'click', function (event) { const effectRow = event.target.closest( '[data-tick-effect-row]' ); if (!effectRow) { return; } const addConditional = event.target.closest( '[data-add-tick-conditional-result]' ); if (addConditional) { if (!conditionalTemplate) { return; } const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } const effectIndex = effectRow.dataset.tickIndex; const branchIndex = list.querySelectorAll( ':scope > [data-tick-effect-branch]' ).length; const html = conditionalTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } const otherwise = Array .from( list.children ) .find( function (child) { return ( child.dataset && child.dataset.branchKind === 'otherwise' ); } ); if (otherwise) { list.insertBefore( branch, otherwise ); } else { list.appendChild( branch ); } wayfarerTickEffectReindexBranches( effectRow ); const newIndex = branch.dataset.branchIndex; const host = branch.querySelector( '[data-new-branch-condition-editor]' ); if (host) { wayfarerTickEffectBuildConditionEditor( host, 'tick_effects[' + effectIndex + '][branches][' + newIndex + '][conditions]', conditionTargets ); } wayfarerTickEffectRefreshBranchControls( effectRow ); return; } const addOtherwise = event.target.closest( '[data-add-tick-otherwise-result]' ); if (addOtherwise) { if (!otherwiseTemplate) { return; } const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } if ( list.querySelector( '[data-branch-kind="otherwise"]' ) ) { return; } const effectIndex = effectRow.dataset.tickIndex; const branchIndex = list.querySelectorAll( ':scope > [data-tick-effect-branch]' ).length; const html = otherwiseTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } list.appendChild( branch ); wayfarerTickEffectRefreshBranchControls( effectRow ); return; } const removeBranch = event.target.closest( '[data-remove-tick-branch]' ); if (removeBranch) { const branch = removeBranch.closest( '[data-tick-effect-branch]' ); if (branch) { branch.remove(); wayfarerTickEffectRefreshBranchControls( effectRow ); } return; } const moveUp = event.target.closest( '[data-move-tick-branch-up]' ); const moveDown = event.target.closest( '[data-move-tick-branch-down]' ); if ( !moveUp && !moveDown ) { return; } const branch = event.target.closest( '[data-tick-effect-branch]' ); const list = effectRow.querySelector( '[data-tick-branch-list]' ); if ( !branch || !list || branch.dataset.branchKind !== 'conditional' ) { return; } const conditionals = Array.from( list.children ).filter( function (child) { return ( child.dataset && child.dataset.branchKind === 'conditional' ); } ); const index = conditionals.indexOf( branch ); if ( moveUp && index > 0 ) { list.insertBefore( branch, conditionals[index - 1] ); } if ( moveDown && index >= 0 && index < conditionals.length - 1 ) { const next = conditionals[index + 1]; list.insertBefore( next, branch ); } wayfarerTickEffectRefreshBranchControls( effectRow ); } ); /* * Existing Tick Effect code creates whole effect rows from its * template. Refresh branch controls after those rows appear. */ const list = wrapper.querySelector( '[data-tick-effects-list]' ); if (list) { const observer = new MutationObserver( function (mutations) { mutations.forEach( function (mutation) { mutation.addedNodes .forEach( function (node) { if ( !node.matches || !node.matches( '[data-tick-effect-row]' ) ) { return; } wayfarerTickEffectRefreshBranchControls( node ); } ); } ); } ); observer.observe( list, { childList: true } ); } } ); } initializeTickEffectResultBranches(); ================================================================================ FILE: update-backups/v56-20260925-202033/config/builder-types.php ================================================================================ [ 'label' => 'Regions', 'singular' => 'Region', 'table' => 'wf_regions', 'list_field' => 'name', 'traits' => true, /* * Permanent deletion is available from the edit screen. * A Region cannot be deleted while Locations still belong to it. */ 'delete' => [ 'enabled' => true, 'dependencies' => [ [ 'table' => 'wf_locations', 'foreign_key' => 'region_id', 'label' => 'Locations' ] ] ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'navigation_type' => [ 'label' => 'Navigation Type', 'type' => 'select', 'required' => true, 'options' => [ 'linear' => 'Linear', 'grid' => 'Grid', 'freeform' => 'Freeform' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ] ], 'locations' => [ 'label' => 'Locations', 'singular' => 'Location', 'table' => 'wf_locations', 'list_field' => 'name', 'traits' => true, 'list_columns' => [ 'name' => [ 'label' => 'Name', 'source' => 'field', 'field' => 'name', 'sortable' => true ], 'region' => [ 'label' => 'Region', 'source' => 'relationship', 'relationship' => 'region_id', 'sortable' => true ], 'position' => [ 'label' => 'Position', 'source' => 'config', 'field' => 'position', 'type' => 'number', 'sortable' => true ] ], /* * Permanent deletion is available from the edit screen. */ 'delete' => [ 'enabled' => true ], 'scope' => [ 'type' => 'region', 'field' => 'region_id', 'scope_table' => 'wf_regions', 'scope_key' => 'id', 'game_field' => 'game_id' ], 'relationships' => [ 'region_id' => [ 'label' => 'Region', 'type' => 'select_record', 'table' => 'wf_regions', 'value_field' => 'id', 'label_field' => 'name', 'game_field' => 'game_id', 'required' => true ] ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], 'config_fields' => [ 'position' => [ 'label' => 'Position', 'type' => 'number', 'min' => 0, 'required' => true, 'condition' => [ 'relationship' => 'region_id', 'source_table' => 'wf_regions', 'source_key' => 'id', 'source_field' => 'navigation_type', 'equals' => 'linear' ] ] ] ], 'entities' => [ 'label' => 'Entities', 'singular' => 'Entity', 'table' => 'wf_entities', 'list_field' => 'name', 'traits' => true, /* * Permanent deletion is available from the edit screen. * Additional dependency rules can be added here later as * Storylets and other authored objects begin referencing Entities. */ 'delete' => [ 'enabled' => true ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'entity_type' => [ 'label' => 'Entity Type', 'type' => 'select', 'required' => true, 'default' => 'item', 'options' => [ 'item' => 'Item', 'character' => 'Character' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], /* * System traits use the same stored config.traits collection as * author-created traits. They are locked in the authoring UI. */ 'system_traits' => [ 'selector_field' => 'entity_type', 'after_field' => 'entity_type', 'groups' => [ 'item' => [ 'label' => 'Item Settings', 'help' => 'These are required Item properties used by Wayfarer. ' . 'They cannot be removed or renamed.', 'traits' => [ 'quantity' => [ /* * WAYFARER_ITEM_QUANTITY_CONTEXT_V44 */ 'label' => 'Starting Quantity', 'type' => 'number', 'default' => 0, 'min' => 0, 'required' => true, /* * The minimum is a Wayfarer system rule, not an * author-editable gameplay setting. */ 'show_system_min' => true, 'min_label' => 'Minimum Quantity', 'min_help' => 'Wayfarer Item quantities cannot fall below 0.', 'allow_author_max' => true, 'max_label' => 'Maximum Quantity', 'max_help' => 'The maximum quantity the player can hold during gameplay. ' . 'Leave blank for no maximum.', 'help' => 'Sets the quantity of this Item when gameplay is initialized. ' . 'Effects may increase or decrease the quantity during play. ' . 'A Starting Quantity of 0 means the player begins without this Item.' ], 'inventory_visibility' => [ 'label' => 'Inventory Visibility', 'type' => 'select', 'default' => 'while_possessed', 'required' => true, 'options' => [ 'while_possessed' => 'Only while possessed', 'once_discovered' => 'Once discovered', 'always' => 'Always', 'never' => 'Never' ], 'help' => 'Controls when this item appears in the player inventory. ' . '"Only while possessed" hides the item whenever its quantity ' . 'is 0. "Once discovered" hides it until the player first ' . 'obtains or discovers it, then keeps it visible even if its ' . 'quantity later returns to 0. "Always" shows the item from ' . 'the beginning of the game. "Never" keeps the item hidden ' . 'from the player inventory while still allowing gameplay ' . 'conditions and effects to use it.' ] ] ], 'character' => [ 'label' => 'Character Settings', 'help' => 'These are required Character properties used by Wayfarer. ' . 'They cannot be removed or renamed.', 'traits' => [ 'party_member' => [ 'label' => 'Starts in Party', 'type' => 'binary', 'default' => 0, 'required' => true, 'help' => 'Determines whether this character begins the game ' . 'as a member of the player party. No stores 0; ' . 'Yes stores 1. Storylets can later change this ' . 'state to add or remove the character from the party.' ] ] ] ] ] ], /* WAYFARER_STORYLET_BUILDER_V56 */ 'storylets' => [ 'label' => 'Storylets', 'singular' => 'Storylet', 'table' => 'wf_storylets', 'list_field' => 'name', 'list_columns' => [ 'name' => [ 'label' => 'Name', 'source' => 'field', 'field' => 'name', 'sortable' => true ], 'parent' => [ 'label' => 'Parent', 'source' => 'relationship', 'relationship' => 'parent_storylet_id', 'sortable' => true ], 'trigger' => [ 'label' => 'Trigger', 'source' => 'field', 'field' => 'trigger_type', 'sortable' => true ], 'status' => [ 'label' => 'Status', 'source' => 'field', 'field' => 'status', 'sortable' => true ] ], /* * A Storylet with children cannot be permanently removed until its * Choice Storylets have been detached or deleted. */ 'delete' => [ 'enabled' => true, 'dependencies' => [ [ 'table' => 'wf_storylets', 'foreign_key' => 'parent_storylet_id', 'label' => 'Choice Storylets' ] ] ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'relationships' => [ 'parent_storylet_id' => [ 'label' => 'Parent Storylet', 'type' => 'select_record', 'table' => 'wf_storylets', 'value_field' => 'id', 'label_field' => 'name', 'game_field' => 'game_id', 'required' => false, 'exclude_current' => true, 'help' => 'Leave blank for a root Storylet. Select a parent to make ' . 'this Storylet one of that Storylet\'s player Choices.' ] ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true, 'help' => 'Author-facing Storylet name and default player-facing title.' ], 'description' => [ 'label' => 'Narrative / Description', 'type' => 'textarea', 'help' => 'Narrative shown when this Storylet opens. It may also be ' . 'used as preview text when the Storylet is offered.' ], 'choice_label' => [ 'label' => 'Choice Label', 'type' => 'text', 'help' => 'Used when this Storylet appears as a child Choice. Leave ' . 'blank to use the Storylet Name.' ], 'choice_sort_order' => [ 'label' => 'Choice Order', 'type' => 'number', 'min' => 0, 'default' => 0, 'help' => 'Lower numbers appear first among sibling Choices. Ignored ' . 'for root Storylets.' ], 'trigger_type' => [ 'label' => 'Trigger Type', 'type' => 'select', 'required' => true, 'default' => 'manual', 'options' => [ 'manual' => 'Manual', 'automatic' => 'Automatic', 'chance' => 'Chance' ], 'help' => 'Manual: offer while eligible. Automatic: fire when it becomes ' . 'eligible. Chance: while eligible, roll the base chance each tick.' ], 'max_occurrences' => [ 'label' => 'Maximum Occurrences', 'type' => 'number', 'min' => 1, 'help' => 'Leave blank for unlimited occurrences.' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], 'config_fields' => [ 'priority' => [ 'label' => 'Priority', 'type' => 'number', 'default' => 100, 'help' => 'Author-controlled gameplay order for competing Storylets. ' . 'Lower numbers are evaluated first.' ], 'tick_cost' => [ 'label' => 'Tick Cost', 'type' => 'number', 'min' => 0, 'default' => 0, 'help' => 'How many normal gameplay ticks resolving this Storylet consumes. ' . '0 consumes no time; 1 or more runs the normal tick pipeline.' ], 'chance_percent' => [ 'label' => 'Base Chance %', 'type' => 'number', 'min' => 0, 'max' => 100, 'step' => 0.01, 'help' => 'Used only by Chance Storylets. Eligibility is checked first; ' . 'then this percentage is rolled on each gameplay tick.' ], 'interrupts_travel' => [ 'label' => 'Interrupts Travel', 'type' => 'select', 'default' => '0', 'options' => [ '0' => 'No', '1' => 'Yes' ], 'help' => 'Used by Automatic and Chance Storylets. If Yes, firing the ' . 'Storylet stops the current travel activity. Manual Storylets ' . 'are selected by the player and do not use this setting.' ] ] ] ]; ================================================================================ FILE: update-backups/v56-20260925-202033/includes/builder/location-tick-effects.php ================================================================================ whether the Location is eligible to trigger. | Effects -> one-time state changes when it triggers. | | Conditions and Effects have separate builder tabs. | | Current target model: | - Game Traits | - Current Region Traits | - Navigation Position when the parent Region is linear | | Location Traits are not runtime targets yet because mutable per-Location | Trait state has not been introduced. | | Expected variables: | $pdo | $gameId | $game | $record | $id | */ require_once __DIR__ . '/../gameplay/functions.php'; $locationGameplayConfig = []; if ( !empty( $record['config'] ) ) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $locationGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted one-time Effect rows. * An absent effects collection during POST is canonical empty, which allows * removal of the final Effect. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' ) { $gameplayEffects = isset( $_POST['effects'] ) && is_array( $_POST['effects'] ) ? array_values( $_POST['effects'] ) : []; } else { $gameplayEffects = isset( $locationGameplayConfig['effects'] ) && is_array( $locationGameplayConfig['effects'] ) ? array_values( $locationGameplayConfig['effects'] ) : []; } /* * Failed POSTs retain the author's submitted Tick Effect rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' ) { $gameplayTickEffects = isset( $_POST['tick_effects'] ) && is_array( $_POST['tick_effects'] ) ? array_values( $_POST['tick_effects'] ) : []; } else { $gameplayTickEffects = isset( $locationGameplayConfig['tick_effects'] ) && is_array( $locationGameplayConfig['tick_effects'] ) ? array_values( $locationGameplayConfig['tick_effects'] ) : []; } /* * Game Trait targets. */ $gameConfig = []; if ( !empty( $game['config'] ) ) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * Parent Region context supplies Region Trait targets and determines whether * Navigation Position is valid for this Location. */ $parentRegionTraits = []; $parentRegionNavigationType = ''; $parentRegionId = (int)( $record['region_id'] ?? 0 ); if ($parentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $parentRegionId, $gameId, ]); $parentRegion = $regionStmt->fetch( PDO::FETCH_ASSOC ); if ($parentRegion) { $parentRegionNavigationType = (string)( $parentRegion['navigation_type'] ?? '' ); if ( !empty( $parentRegion['config'] ) ) { $decoded = json_decode( (string)$parentRegion['config'], true ); if ( is_array($decoded) && isset( $decoded['traits'] ) && is_array( $decoded['traits'] ) ) { $parentRegionTraits = $decoded['traits']; } } } } $gameplayScopeTraitTargets = gameplayNumericTraitTargets( $parentRegionTraits, 'region' ); $gameplayScopeKey = 'region'; $gameplayScopeLabel = 'Region'; $gameplayAllowNavigation = strtolower( trim( $parentRegionNavigationType ) ) === 'linear'; $gameplayNavigationType = $parentRegionNavigationType; $gameplayConditionTargets = gameplayConditionAllowedTargets( $gameConfig['traits'] ?? [], $parentRegionTraits, 'region', 'Region' ); /* * WAYFARER_LOCATION_CONDITIONS_V45 * * Empty Conditions mean the Location is always eligible. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' ) { $locationGameplayConditions = $_POST['conditions'] ?? null; } else { $locationGameplayConditions = $locationGameplayConfig[ 'conditions' ] ?? null; } $gameplayEffectDescription = "These Effects run once when this Location is triggered."; $gameplayAllowEffectNavigation = false; /* * WAYFARER_LOCATION_EFFECTS_ONLY_V43 * * Locations are event boundaries. Recurring simulation belongs to Game and * Region Tick Effects; Location state changes belong to one-time Effects. */ ?>

Conditions

Optional. The Location triggers only when these conditions are true at the moment the player reaches or crosses its Position. If they are false, travel passes through the Location normally.

prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } $allowedTargets = gameplayTickEffectAllowedTargets( $gameConfig['traits'] ?? [], $regionTraits, 'region', ($record['navigation_type'] ?? '') === 'linear' ); $allowedConditionTargets = gameplayConditionAllowedTargets( $gameConfig['traits'] ?? [], $regionTraits, 'region', 'Region' ); return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayConditionAllowedTargets( $gameConfig['traits'] ?? [], $regionConfig['traits'] ?? [], 'region', 'Region' ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayEffectAllowedTargets( $gameConfig['traits'] ?? [], $regionTraits, 'region', false ), $postedEffects, gameplayConditionAllowedTargets( $gameConfig['traits'] ?? [], $regionTraits, 'region', 'Region' ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayTickEffectAllowedTargets( $gameConfig['traits'] ?? [], $regionTraits, 'region', $allowLinearNavigation ); $allowedConditionTargets = gameplayConditionAllowedTargets( $gameConfig['traits'] ?? [], $regionTraits, 'region', 'Region' ); return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayEffectAllowedTargets( $gameConfig['traits'] ?? [] ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v56-20260925-202033/includes/builder/storylet-gameplay.php ================================================================================

Conditions

Conditions determine whether this Storylet is eligible. A root Manual Storylet can be offered only while these are true. Automatic and Chance Storylets can fire only while eligible. For a child Storylet, these same Conditions determine whether its Choice is available to the player.

Effects

These Effects run once when the player enters this Storylet. Each Effect may use ordered conditional results and an optional Otherwise result. The current shared one-time Effect engine exposes numeric Game Traits as Storylet targets; its result Conditions may inspect Game and Region state.

Choices

A Choice is another Storylet whose Parent Storylet is this Storylet. The child Storylet's Conditions decide whether the Choice is available; entering that child runs its Effects. Children may have their own Choices, so this structure can nest to any practical depth without a separate branch-object schema.

Save this Storylet first. After it has an ID, you can add child Choice Storylets here.

prepare( " SELECT id, name, choice_label, choice_sort_order, trigger_type, status FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? ORDER BY choice_sort_order, name, id " ); $choiceStmt->execute([ $gameId, (int)$id, ]); $storyletChoices = $choiceStmt->fetchAll( PDO::FETCH_ASSOC ); ?>

+ New Choice Storylet

No child Choices have been created yet.

Order Choice Storylet Trigger Status
================================================================================ FILE: update-backups/v56-20260925-202033/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * Current Region trait targets. */ $gameplayScopeTraitTargets = gameplayNumericTraitTargets( $traits, 'region' ); /* * Shared editor context. */ $gameplayScopeKey = 'region'; $gameplayScopeLabel = 'Region'; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v56-20260925-202033/includes/gameplay/effects-editor.php ================================================================================ operation/value * OTHERWISE -> operation/value * * First matching result wins. Each result may use a Fixed value or roll a * Random Min-Max value once when the Effect executes. */ $gameplayEffects = isset( $gameplayEffects ) && is_array( $gameplayEffects ) ? array_values( $gameplayEffects ) : []; $gameplayGameTraitTargets = $gameplayGameTraitTargets ?? []; $gameplayScopeTraitTargets = $gameplayScopeTraitTargets ?? []; $gameplayScopeKey = (string)( $gameplayScopeKey ?? '' ); $gameplayScopeLabel = (string)( $gameplayScopeLabel ?? '' ); $gameplayAllowEffectNavigation = !empty( $gameplayAllowEffectNavigation ); $gameplayEffectDescription = (string)( $gameplayEffectDescription ?? 'These Effects run once when this object is triggered.' ); $gameplayConditionTargets = isset( $gameplayConditionTargets ) && is_array( $gameplayConditionTargets ) ? $gameplayConditionTargets : []; $renderEffectTargetOptions = function ( array $effect = [] ) use ( $gameplayAllowEffectNavigation, $gameplayGameTraitTargets, $gameplayScopeKey, $gameplayScopeLabel, $gameplayScopeTraitTargets ): void { echo ''; if ($gameplayAllowEffectNavigation) { echo ''; echo ''; echo ''; } if ($gameplayGameTraitTargets) { echo ''; foreach ( $gameplayGameTraitTargets as $targetTrait ) { echo ''; } echo ''; } if ( $gameplayScopeKey !== '' && $gameplayScopeTraitTargets ) { echo ''; foreach ( $gameplayScopeTraitTargets as $targetTrait ) { echo ''; } echo ''; } }; $renderEffectResultFields = function ( array $branch, string $name ): void { $operation = gameplayEffectBranchOperation( $branch ); $valueMode = gameplayEffectBranchValueMode( $branch ); $value = gameplayEffectBranchValue( $branch ); $minimum = gameplayEffectBranchMin( $branch ); $maximum = gameplayEffectBranchMax( $branch ); $noChange = $operation === 'none'; ?>
>

Effects

Each Effect controls one target. Results are checked from top to bottom; the first matching result wins. Random values are rolled once when the Effect fires.

$effect ): ?>
$branch ): ?>
THEN

================================================================================ FILE: update-backups/v56-20260925-202033/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $normalized['value'] = $branch['value'] ?? ''; } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect: * operation/value/conditions become one branch. * * No conditions -> OTHERWISE. * Existing conditions -> conditional branch. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'conditions', $effect ) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $branch['value'] = $effect['value'] ?? ''; } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( !in_array( $operation, [ 'add', 'subtract', 'set', 'none', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Effect result operation.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) ) { return [ 'effects' => [], 'error' => 'Effect value type must be Fixed or Random.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); if ( $valueRaw === '' || !is_numeric( $valueRaw ) ) { return [ 'effects' => [], 'error' => 'Fixed Effect results require a numeric value.', ]; } $storedBranch[ 'value' ] = gameplayEffectNormalizeNumber( $valueRaw ); } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( !in_array( $operation, [ 'add', 'subtract', 'set', 'none', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result operation.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $valueRaw === '' || !is_numeric( $valueRaw ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect result value must be a number.', ]; } if ( filter_var( $valueRaw, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$valueRaw; } else { $value = (float)$valueRaw; } $storedBranch['value'] = $value; } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } $targetDefinition = $allowedTargets[$target]; /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { $targets = gameplayConditionAllowedTargets( $gameTraits ); if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); $regions = $stmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($regions as $region) { $regionId = (int)( $region['id'] ?? 0 ); if ($regionId <= 0) { continue; } $config = []; if ( isset($region['config']) && $region['config'] !== null && $region['config'] !== '' ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $config = $decoded; } } $regionTraits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; $regionName = trim( (string)( $region['name'] ?? '' ) ); if ($regionName === '') { $regionName = 'Region ' . $regionId; } $targets = array_merge( $targets, gameplayConditionTraitTargets( $regionTraits, 'region:' . $regionId, 'Region — ' . $regionName ) ); } return $targets; } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => wayfarerConditionNormalizeScalar( $value ), ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v56-20260925-202033/includes/gameplay/starting-choices.php ================================================================================ prepare( " SELECT id, name, config FROM wf_entities WHERE game_id = ? AND status = 'active' AND LOWER(entity_type) = 'item' ORDER BY name, id " ); $stmt->execute([ $gameId ]); $rows = $stmt->fetchAll( PDO::FETCH_ASSOC ); $targets = []; foreach ($rows as $row) { $entityId = (int)( $row['id'] ?? 0 ); if ($entityId <= 0) { continue; } $config = []; if ( isset($row['config']) && $row['config'] !== null && $row['config'] !== '' ) { $decoded = json_decode( (string)$row['config'], true ); if (is_array($decoded)) { $config = $decoded; } } /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Quantity is universal for Item Entities. Its definition is Wayfarer * schema, while this Item's initial/max values live in config.system. */ $quantityTraitId = 'sys_item_quantity'; $targets[] = [ 'entity_id' => $entityId, 'name' => trim( (string)( $row['name'] ?? ( 'Item ' . $entityId ) ) ), 'target_scope' => 'entity:' . $entityId, 'target_type' => 'trait', 'target_id' => $quantityTraitId, 'target_system_key' => 'quantity', ]; } return $targets; } function wayfarerStartingChoiceEffectTarget( array $effect ): string { if (!empty($effect['target'])) { return (string)$effect['target']; } $scope = (string)( $effect['target_scope'] ?? '' ); $type = (string)( $effect['target_type'] ?? '' ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); if ( $scope === 'game' && $type === 'trait' && $targetId !== '' ) { return 'game:' . $targetId; } if ( $scope === 'player' && $type === 'trait' && $targetId !== '' ) { $selector = strtolower( trim( (string)( $effect['target_selector'] ?? '' ) ) ); if ($selector === 'all') { return 'player_all:' . $targetId; } if ( $selector === 'slot' && array_key_exists( 'target_slot', $effect ) ) { return 'player_slot:' . max( 0, (int)$effect['target_slot'] ) . ':' . $targetId; } } if ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope ) === 1 ) { return $scope . ':' . $targetId; } return ''; } /* * Build the complete target map used by both authoring validation and the * Starting Choice target selector. */ function wayfarerStartingChoiceAllowedTargets( array $gameTraits, array $playerTraits = [], int $playerSlots = 0, array $itemQuantityTargets = [] ): array { $allowed = []; foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $trait ) { $traitId = (string)( $trait['id'] ?? '' ); if ($traitId === '') { continue; } $allowed[ 'game:' . $traitId ] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $traitId, 'group' => 'Game Traits', 'label' => 'Game → ' . (string)( $trait['name'] ?? $traitId ), ]; } $playerTargets = gameplayNumericTraitTargets( $playerTraits, 'player' ); $playerSlots = max( 0, $playerSlots ); if ( $playerSlots > 0 && !empty($playerTargets) ) { foreach ($playerTargets as $trait) { $traitId = (string)( $trait['id'] ?? '' ); if ($traitId === '') { continue; } $allowed[ 'player_all:' . $traitId ] = [ 'target_scope' => 'player', 'target_type' => 'trait', 'target_id' => $traitId, 'target_selector' => 'all', 'group' => 'Player Traits — All Players', 'label' => 'All Players → ' . (string)( $trait['name'] ?? $traitId ), ]; } for ( $slot = 0; $slot < $playerSlots; $slot++ ) { foreach ($playerTargets as $trait) { $traitId = (string)( $trait['id'] ?? '' ); if ($traitId === '') { continue; } $allowed[ 'player_slot:' . $slot . ':' . $traitId ] = [ 'target_scope' => 'player', 'target_type' => 'trait', 'target_id' => $traitId, 'target_selector' => 'slot', 'target_slot' => $slot, 'group' => 'Player Character ' . ($slot + 1), 'label' => 'Player ' . ($slot + 1) . ' → ' . (string)( $trait['name'] ?? $traitId ), ]; } } } foreach ($itemQuantityTargets as $itemTarget) { if (!is_array($itemTarget)) { continue; } $scope = trim( (string)( $itemTarget['target_scope'] ?? '' ) ); $targetId = trim( (string)( $itemTarget['target_id'] ?? '' ) ); $entityId = (int)( $itemTarget['entity_id'] ?? 0 ); if ( $entityId <= 0 || !preg_match( '/^entity:[1-9][0-9]*$/', $scope ) || $targetId === '' ) { continue; } $key = $scope . ':' . $targetId; $allowed[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $targetId, 'target_system_key' => 'quantity', 'group' => 'Items', 'label' => (string)( $itemTarget['name'] ?? ( 'Item ' . $entityId ) ) . ' → Quantity', ]; } return $allowed; } function wayfarerBuildStartingChoicesFromPost( array $postedChoices, array $gameTraits, array $playerTraits = [], int $playerSlots = 0, array $itemQuantityTargets = [] ): array { $allowedTargets = wayfarerStartingChoiceAllowedTargets( $gameTraits, $playerTraits, $playerSlots, $itemQuantityTargets ); /* * Legacy migration map: * * V52 allowed authors to move a definition from Game Traits to Player * Traits while keeping its Trait ID, but V36 Starting Choices still held * game:TRAIT_ID. When that exact ID is now a Player Trait, interpret the * old target as All Player Characters. */ $legacyPlayerTraitTargets = []; foreach ( gameplayNumericTraitTargets( $playerTraits, 'player' ) as $trait ) { $traitId = (string)( $trait['id'] ?? '' ); if ($traitId === '') { continue; } $legacyPlayerTraitTargets[ 'game:' . $traitId ] = 'player_all:' . $traitId; } $choices = []; $seenChoiceIds = []; foreach ( array_values($postedChoices) as $choiceIndex => $postedChoice ) { if (!is_array($postedChoice)) { continue; } $prompt = trim( (string)( $postedChoice['prompt'] ?? '' ) ); $postedOptions = isset($postedChoice['options']) && is_array($postedChoice['options']) ? array_values( $postedChoice['options'] ) : []; /* * Completely blank newly-added card: ignore it. */ if ( $prompt === '' && $postedOptions === [] ) { continue; } if ($prompt === '') { return [ 'choices' => [], 'error' => 'Each Starting Choice must have a prompt.', ]; } $choiceId = wayfarerStartingChoiceValidId( $postedChoice['id'] ?? null ) ? (string)$postedChoice['id'] : wayfarerStartingChoiceMakeId( 'choice' ); if (isset($seenChoiceIds[$choiceId])) { return [ 'choices' => [], 'error' => 'Starting Choice IDs must be unique.', ]; } $seenChoiceIds[$choiceId] = true; $required = !empty( $postedChoice['required'] ); $options = []; $seenOptionIds = []; foreach ( $postedOptions as $optionIndex => $postedOption ) { if (!is_array($postedOption)) { continue; } $label = trim( (string)( $postedOption['label'] ?? '' ) ); $description = trim( (string)( $postedOption['description'] ?? '' ) ); $postedEffects = isset($postedOption['effects']) && is_array($postedOption['effects']) ? array_values( $postedOption['effects'] ) : []; if ( $label === '' && $description === '' && $postedEffects === [] ) { continue; } if ($label === '') { return [ 'choices' => [], 'error' => 'Each Starting Choice option must have a label.', ]; } $optionId = wayfarerStartingChoiceValidId( $postedOption['id'] ?? null ) ? (string)$postedOption['id'] : wayfarerStartingChoiceMakeId( 'option' ); if (isset($seenOptionIds[$optionId])) { return [ 'choices' => [], 'error' => 'Starting Choice option IDs must be unique within a choice.', ]; } $seenOptionIds[$optionId] = true; $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $operation = trim( (string)( $postedEffect['operation'] ?? '' ) ); $valueRaw = trim( (string)( $postedEffect['value'] ?? '' ) ); /* * Empty newly-added effect row. */ if ( $target === '' && $operation === '' && $valueRaw === '' ) { continue; } /* * Preserve the intent of a Trait moved from Game -> Player. */ if ( !isset($allowedTargets[$target]) && isset( $legacyPlayerTraitTargets[$target] ) && isset( $allowedTargets[ $legacyPlayerTraitTargets[$target] ] ) ) { $target = $legacyPlayerTraitTargets[$target]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'choices' => [], 'error' => 'Each Starting Choice effect must use a valid Game or Player Trait target.', ]; } if ( !in_array( $operation, [ 'add', 'subtract', 'set', ], true ) ) { return [ 'choices' => [], 'error' => 'Starting Choice effects must Add, Subtract, or Set.', ]; } if ( $valueRaw === '' || !is_numeric($valueRaw) ) { return [ 'choices' => [], 'error' => 'Each Starting Choice effect must have a numeric value.', ]; } $value = filter_var( $valueRaw, FILTER_VALIDATE_INT ) !== false ? (int)$valueRaw : (float)$valueRaw; $targetDefinition = $allowedTargets[$target]; $effect = [ 'target_scope' => (string)$targetDefinition['target_scope'], 'target_type' => 'trait', 'target_id' => (string)$targetDefinition['target_id'], 'operation' => $operation, 'value' => $value, ]; if ( !empty( $targetDefinition[ 'target_system_key' ] ) ) { $effect['target_system_key'] = (string)$targetDefinition[ 'target_system_key' ]; } if ( $targetDefinition['target_scope'] === 'player' ) { $effect['target_selector'] = (string)( $targetDefinition[ 'target_selector' ] ?? 'all' ); if ( $effect['target_selector'] === 'slot' ) { $effect['target_slot'] = (int)( $targetDefinition[ 'target_slot' ] ?? 0 ); } } $effects[] = $effect; } $options[] = [ 'id' => $optionId, 'label' => $label, 'description' => $description, 'effects' => $effects, ]; } if ($options === []) { return [ 'choices' => [], 'error' => 'Each Starting Choice must have at least one option.', ]; } $choices[] = [ 'id' => $choiceId, 'prompt' => $prompt, 'required' => $required, 'options' => $options, ]; } return [ 'choices' => $choices, 'error' => '', ]; } function wayfarerRenderStartingChoiceTargetOptions( array $gameTraits, array $playerTraits = [], int $playerSlots = 0, array $itemQuantityTargets = [], string $selected = '' ): void { $targets = wayfarerStartingChoiceAllowedTargets( $gameTraits, $playerTraits, $playerSlots, $itemQuantityTargets ); /* * If an existing option still stores game:TRAIT_ID but that ID now lives * under Player Traits, present it as All Players instead of losing the * selection in the editor. */ if ( $selected !== '' && !isset($targets[$selected]) && strpos( $selected, 'game:' ) === 0 ) { $traitId = substr( $selected, strlen('game:') ); $candidate = 'player_all:' . $traitId; if (isset($targets[$candidate])) { $selected = $candidate; } } echo ''; $grouped = []; foreach ($targets as $value => $target) { $group = (string)( $target['group'] ?? 'Traits' ); if (!isset($grouped[$group])) { $grouped[$group] = []; } $grouped[$group][$value] = $target; } foreach ($grouped as $group => $options) { echo ''; foreach ($options as $value => $target) { echo ''; } echo ''; } } function wayfarerRenderStartingChoiceEffect( array $effect = [] ): void { $target = wayfarerStartingChoiceEffectTarget( $effect ); $operation = (string)( $effect['operation'] ?? 'set' ); $value = $effect['value'] ?? ''; ?>
Option
Starting Effects Optional. Effects may target numeric Game Traits or Player Traits.
Starting Choice

Starting Choices

Optional one-of-many questions shown before the game begins. The selected option is saved in runtime state. Option effects can adjust numeric Game Traits or Player Traits during initialization.

Select target...'; if ($gameplayAllowNavigation) { echo ''; echo ''; echo ''; } if ($gameplayGameTraitTargets) { echo ''; foreach ( $gameplayGameTraitTargets as $targetTrait ) { echo ''; } echo ''; } if ( $gameplayScopeKey !== '' && $gameplayScopeTraitTargets ) { echo ''; foreach ( $gameplayScopeTraitTargets as $targetTrait ) { echo ''; } echo ''; } }; $renderBranchResultFields = function ( array $branch, string $name ): void { $operation = gameplayTickEffectBranchOperation( $branch ); $value = gameplayTickEffectBranchValue( $branch ); ?>
data-gameplay-scope="" data-navigation-type="" >

Tick Effects

Each Tick Effect controls one target. Its results are checked from top to bottom. The first matching result wins within that Tick Effect. Separate Tick Effects remain independent.

$tickEffect ): ?>
$branch ): ?>
THEN

================================================================================ FILE: update-backups/v56-20260925-202033/play/includes/effects.php ================================================================================ true, 'value' => wayfarerNormalizeRuntimeNumber( (float)$effectValue ), 'error' => '', ]; } return [ 'applied' => true, 'value' => $effectValue, 'error' => '', ]; } if ( $operation !== 'add' && $operation !== 'subtract' ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Unsupported operation: ' . $operation, ]; } if ( !is_numeric($currentValue) || !is_numeric($effectValue) ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Add/subtract requires numeric values.', ]; } $current = (float)$currentValue; $amount = (float)$effectValue; $result = $operation === 'add' ? $current + $amount : $current - $amount; return [ 'applied' => true, 'value' => wayfarerNormalizeRuntimeNumber( $result ), 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Tick Effect branch runtime |-------------------------------------------------------------------------- | | A Tick Effect owns one target and an ordered list of result branches. | Conditions are evaluated against the CURRENT runtime state. Because Tick | Effects execute sequentially, a later Tick Effect can see state changes made | by an earlier Tick Effect during the same tick. | */ function wayfarerRuntimeResolveConditionTarget( array $runtimeState, int $currentRegionId, string $scope, string $targetType, string $targetId ): array { $scope = strtolower( trim( $scope ) ); $targetType = strtolower( trim( $targetType ) ); $targetId = trim( $targetId ); // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $targetType === 'trait' && $targetId !== '' ) { $traits = $runtimeState['game']['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // SPECIFIC REGION TRAIT // WAYFARER_SPECIFIC_REGION_CONDITION_SCOPE_V46 // ------------------------------------------------------------ // // Game-level condition editors store a Region-owned Trait as // "region:". This lets a global rule inspect that Region whether // or not the player is currently there. // if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^region:([1-9][0-9]*)$/', $scope, $regionScopeMatch ) === 1 ) { $regionKey = (string)( (int)$regionScopeMatch[1] ); $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $targetType === 'trait' && $targetId !== '' && $currentRegionId > 0 ) { $regionKey = (string)$currentRegionId; $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $targetType === 'position' && $targetId === 'position' ) { if ( isset( $runtimeState['navigation'] ) && is_array( $runtimeState['navigation'] ) && array_key_exists( 'position', $runtimeState['navigation'] ) ) { return [ 'found' => true, 'value' => $runtimeState['navigation']['position'], ]; } return [ 'found' => false, 'value' => null, ]; } return [ 'found' => false, 'value' => null, ]; } function wayfarerRuntimeTickEffectBranches( array $effect ): array { /* * v28+ canonical structure. * * If the branches key exists, it is authoritative. An empty branches * array therefore means "this effect has no executable results" rather * than silently falling back to legacy compatibility fields. */ if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * Legacy pre-v28 structure. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'conditions', $effect ) ) { return [ [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $effect['operation'] ?? '', 'value' => $effect['value'] ?? 0, ] ]; } return []; } function wayfarerRuntimeSelectTickEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeTickEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Tick Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; /* * Validate before evaluating. Invalid authored data fails closed and * does NOT fall through to OTHERWISE, because doing so could silently * produce the wrong gameplay result. */ $validation = wayfarerConditionValidateNode( $conditions ); if (!$validation['valid']) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Tick Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Runtime Trait bounds |-------------------------------------------------------------------------- | | WAYFARER_RUNTIME_TRAIT_BOUNDS_V30 | | Authored numeric Trait min/max values are runtime invariants. | State is normalized before condition evaluation and every subsequent | mutation is clamped again before it is written. | */ function wayfarerRuntimeDecodeTraitConfig( $rawConfig ): array { if (is_array($rawConfig)) { return $rawConfig; } if ( $rawConfig === null || $rawConfig === '' ) { return []; } $decoded = json_decode( (string)$rawConfig, true ); return is_array($decoded) ? $decoded : []; } function wayfarerRuntimeIndexTraitDefinitions( array $traits ): array { $definitions = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $definitions[$traitId] = $trait; } return $definitions; } function wayfarerRuntimeLoadTickTraitDefinitions( PDO $pdo, int $gameId, array $regionConfig ): array { $gameConfig = []; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawGameConfig = $stmt->fetchColumn(); if ($rawGameConfig !== false) { $gameConfig = wayfarerRuntimeDecodeTraitConfig( $rawGameConfig ); } } return [ 'game' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['traits'] ?? null ) ? $gameConfig['traits'] : [] ), /* WAYFARER_STARTING_CHOICE_PLAYER_TARGETS_V53 */ 'player' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['player_traits'] ?? null ) ? $gameConfig['player_traits'] : [] ), 'region' => wayfarerRuntimeIndexTraitDefinitions( is_array( $regionConfig['traits'] ?? null ) ? $regionConfig['traits'] : [] ), ]; } function wayfarerRuntimeClampValueToTraitDefinition( $value, ?array $definition ): array { $result = [ 'valid' => true, 'value' => $value, 'clamped' => false, 'min' => null, 'max' => null, 'error' => '', ]; /* * Bounds currently belong only to authored Number Traits. */ if ( !$definition || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' ) { return $result; } if (!is_numeric($value)) { $result['valid'] = false; $result['error'] = 'Numeric Trait runtime state must be numeric.'; return $result; } $min = isset($definition['min']) && $definition['min'] !== '' && is_numeric($definition['min']) ? (float)$definition['min'] : null; $max = isset($definition['max']) && $definition['max'] !== '' && is_numeric($definition['max']) ? (float)$definition['max'] : null; $numeric = (float)$value; $bounded = $numeric; if ( $min !== null && $bounded < $min ) { $bounded = $min; } if ( $max !== null && $bounded > $max ) { $bounded = $max; } $result['value'] = wayfarerNormalizeRuntimeNumber( $bounded ); $result['clamped'] = $bounded !== $numeric; $result['min'] = $min === null ? null : wayfarerNormalizeRuntimeNumber( $min ); $result['max'] = $max === null ? null : wayfarerNormalizeRuntimeNumber( $max ); return $result; } function wayfarerRuntimeApplyBoundedTraitOperation( $storedBefore, string $operation, $effectValue, ?array $definition ): array { /* * Repair an already-illegal persisted value BEFORE applying the new * operation. Example: 120 with max 100 and subtract 10 becomes * 100 - 10 = 90, not 120 - 10 = 110 -> 100. */ $beforeBound = wayfarerRuntimeClampValueToTraitDefinition( $storedBefore, $definition ); if (!$beforeBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $storedBefore, 'requested_after' => $storedBefore, 'after' => $storedBefore, 'clamped' => false, 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $beforeBound['error'], ]; } $normalizedBefore = $beforeBound['value']; $operationResult = wayfarerApplyRuntimeOperation( $normalizedBefore, $operation, $effectValue ); if (!$operationResult['applied']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $normalizedBefore, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $operationResult['error'], ]; } $requestedAfter = $operationResult['value']; $afterBound = wayfarerRuntimeClampValueToTraitDefinition( $requestedAfter, $definition ); if (!$afterBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $afterBound['error'], ]; } return [ 'applied' => true, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $afterBound['value'], 'clamped' => $beforeBound['clamped'] || $afterBound['clamped'], 'min' => $afterBound['min'], 'max' => $afterBound['max'], 'error' => '', ]; } function wayfarerRuntimeClampApplicableTraitState( array &$runtimeState, array $traitDefinitions, int $currentRegionId ): array { $repairs = []; // ------------------------------------------------------------ // GAME TRAITS // ------------------------------------------------------------ $gameTraits = $runtimeState['game']['traits'] ?? null; if (is_array($gameTraits)) { foreach ( $traitDefinitions['game'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $gameTraits ) ) { continue; } $before = $runtimeState['game']['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['game']['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } // ------------------------------------------------------------ // CURRENT REGION TRAITS // ------------------------------------------------------------ $regionKey = (string)$currentRegionId; $regionTraits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if (is_array($regionTraits)) { foreach ( $traitDefinitions['region'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $regionTraits ) ) { continue; } $before = $runtimeState['regions'][$regionKey]['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } return $repairs; } function wayfarerApplyRuntimeTickEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectTickEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection['branch_index'], 'applied' => false, 'error' => $selection['error'], ]; } /* * No conditional branch matched and there was no OTHERWISE. * This is a valid "do nothing this tick" result. */ if (!$selection['matched']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection['branch_index']; $operation = strtolower( trim( (string)( $branch['operation'] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; /* * Explicit No Change: * the branch DID match, so this Tick Effect stops here, but state does * not mutate. */ if ($operation === 'none') { $current = wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $type, $targetId ); return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'amount' => null, 'before' => $current['found'] ? $current['value'] : null, 'after' => $current['found'] ? $current['value'] : null, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'applied' => false, 'no_change' => true, 'error' => '', ]; } /* * Apply ONLY the selected result branch. The legacy effect-level * operation/value fields are deliberately ignored when branches exist. */ $resolvedEffect = $effect; $resolvedEffect['operation'] = $operation; $resolvedEffect['value'] = $branch['value'] ?? 0; $report = wayfarerApplyRuntimeEffect( $runtimeState, $resolvedEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; return $report; } function wayfarerApplyRuntimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); $operation = strtolower( trim( (string)( $effect['operation'] ?? '' ) ) ); $effectValue = $effect['value'] ?? 0; // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $type === 'position' && $targetId === 'position' ) { if ( !isset($runtimeState['navigation']) || !is_array($runtimeState['navigation']) ) { $runtimeState['navigation'] = []; } $before = $runtimeState['navigation']['position'] ?? 0; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); $after = $before; if ( $result['applied'] && !is_numeric($result['value']) ) { $result['applied'] = false; $result['error'] = 'Navigation position must be numeric.'; } if ($result['applied']) { /* * Global navigation rule: * position may never be less than zero. */ $after = max( 0, $result['value'] ); $runtimeState['navigation']['position'] = $after; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $after, 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['game']) || !is_array($runtimeState['game']) ) { $runtimeState['game'] = []; } if ( !isset($runtimeState['game']['traits']) || !is_array($runtimeState['game']['traits']) ) { $runtimeState['game']['traits'] = []; } $before = $runtimeState['game']['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['game'][$targetId] ) && is_array( $traitDefinitions['game'][$targetId] ) ? $traitDefinitions['game'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); /* * Persist the normalized value even when a malformed operation fails * after an existing illegal value was repaired. */ if ( $result['normalized_before'] !== $before ) { $runtimeState['game']['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['game']['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // ITEM ENTITY QUANTITY // WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 // ------------------------------------------------------------ if ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityScopeMatch ) === 1 && ( (string)( $effect['target_system_key'] ?? '' ) === 'quantity' || $targetId === 'sys_item_quantity' ) ) { $entityKey = (string)( (int)$entityScopeMatch[1] ); if ( !isset( $runtimeState['entities'][$entityKey] ) || !is_array( $runtimeState['entities'][$entityKey] ) || !isset( $runtimeState['entities'][$entityKey]['traits'] ) || !is_array( $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Entity runtime state was not initialized.', ]; } if ( !array_key_exists( $targetId, $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Quantity runtime state was not found.', ]; } $before = $runtimeState['entities'][$entityKey]['traits'][$targetId]; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); if ( !$result['applied'] || !is_numeric( $result['value'] ?? null ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $before, 'applied' => false, 'error' => $result['error'] !== '' ? $result['error'] : 'Item Quantity must remain numeric.', ]; } $requestedAfter = wayfarerNormalizeRuntimeNumber( (float)$result['value'] ); /* * Quantity is globally non-negative. */ $after = wayfarerNormalizeRuntimeNumber( max( 0.0, (float)$requestedAfter ) ); $runtimeState['entities'][$entityKey]['traits'][$targetId] = $after; /* * Discovery is persistent. Receiving any positive starting quantity * discovers the Item; setting it back to zero never undiscoveres it. */ if ( (float)$after > 0 ) { $runtimeState['entities'][$entityKey]['discovered'] = true; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_system_key' => 'quantity', 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'requested_after' => $requestedAfter, 'after' => $after, 'clamped' => (float)$after !== (float)$requestedAfter, 'min' => 0, 'max' => null, 'applied' => true, 'error' => '', ]; } // ------------------------------------------------------------ // PLAYER TRAIT // WAYFARER_STARTING_CHOICE_PLAYER_TARGETS_V53 // ------------------------------------------------------------ if ( $scope === 'player' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['players']) || !is_array($runtimeState['players']) ) { $runtimeState['players'] = []; } $selector = strtolower( trim( (string)( $effect['target_selector'] ?? '' ) ) ); $targetKeys = []; if ($selector === 'all') { foreach ( $runtimeState['players'] as $playerKey => $playerState ) { if (is_array($playerState)) { $targetKeys[] = (string)$playerKey; } } } elseif ( $selector === 'slot' && array_key_exists( 'target_slot', $effect ) ) { $slot = max( 0, (int)$effect['target_slot'] ); $targetKeys[] = (string)$slot; } else { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect requires an All Players or Player Slot target.', ]; } if (empty($targetKeys)) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect did not resolve any Player Characters.', ]; } $definition = isset( $traitDefinitions['player'][$targetId] ) && is_array( $traitDefinitions['player'][$targetId] ) ? $traitDefinitions['player'][$targetId] : null; $results = []; $firstError = ''; foreach ($targetKeys as $playerKey) { if ( !isset( $runtimeState['players'][$playerKey] ) || !is_array( $runtimeState['players'][$playerKey] ) ) { $firstError = 'Player Character slot ' . ((int)$playerKey + 1) . ' does not exist in runtime state.'; $results[$playerKey] = [ 'applied' => false, 'error' => $firstError, ]; break; } if ( !isset( $runtimeState['players'][$playerKey]['traits'] ) || !is_array( $runtimeState['players'][$playerKey]['traits'] ) ) { $runtimeState['players'][$playerKey]['traits'] = []; } $before = $runtimeState['players'][$playerKey]['traits'][$targetId] ?? 0; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['after']; } $results[$playerKey] = [ 'slot' => (int)$playerKey, 'name' => (string)( $runtimeState['players'][$playerKey]['name'] ?? ( 'Player ' . ((int)$playerKey + 1) ) ), 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; if ( !$result['applied'] || $result['error'] !== '' ) { $firstError = $result['error'] !== '' ? $result['error'] : 'Player Trait effect could not be applied.'; break; } } $allApplied = $firstError === ''; return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_selector' => $selector, 'target_slot' => $selector === 'slot' ? (int)( $effect['target_slot'] ?? 0 ) : null, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'results' => $results, 'applied' => $allApplied, 'error' => $firstError, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $type === 'trait' && $targetId !== '' ) { $regionKey = (string)$currentRegionId; if ( !isset($runtimeState['regions']) || !is_array($runtimeState['regions']) ) { $runtimeState['regions'] = []; } if ( !isset($runtimeState['regions'][$regionKey]) || !is_array($runtimeState['regions'][$regionKey]) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset($runtimeState['regions'][$regionKey]['traits']) || !is_array($runtimeState['regions'][$regionKey]['traits']) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } $before = $runtimeState['regions'][$regionKey]['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['region'][$targetId] ) && is_array( $traitDefinitions['region'][$targetId] ) ? $traitDefinitions['region'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Unsupported runtime target.', ]; } /* |-------------------------------------------------------------------------- | Game Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_GAME_TICK_EFFECT_RUNTIME_V33 | | Game Tick Effects execute once on every successful runtime tick before | the active Region's Tick Effects. This creates a deterministic layering: | | Game rules -> Region rules | */ function wayfarerApplyGameTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { if ($gameId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active game is available for Game Tick Effects.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { $reports = [ [ 'applied' => false, 'error' => 'Game ' . $gameId . ' was not found.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $gameConfig = []; if ( $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, [] ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $gameEffects = $gameConfig['tick_effects'] ?? []; if (!is_array($gameEffects)) { $gameEffects = []; } $reports = []; foreach ($gameEffects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Game Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; $runtimeState['runtime']['last_game_trait_bound_repairs'] = $boundRepairs; return $reports; } /* |-------------------------------------------------------------------------- | Generalized one-time Effect runtime | WAYFARER_GENERALIZED_EFFECT_RUNTIME_V43 |-------------------------------------------------------------------------- | | Conditions use the same shared evaluator as Tick Effect branches. | Random values are resolved exactly once, immediately before the selected | result mutates runtime state. | */ function wayfarerRuntimeEffectBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * v42 compatibility: one Effect was one unconditional operation/value. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $branch = [ 'conditions' => null, 'operation' => $effect['operation'] ?? 'add', 'value_mode' => $effect['value_mode'] ?? 'fixed', ]; if ( strtolower( trim( (string)( $branch[ 'value_mode' ] ) ) ) === 'random' ) { $branch['value_min'] = $effect['value_min'] ?? 0; $branch['value_max'] = $effect['value_max'] ?? 0; } else { $branch['value'] = $effect['value'] ?? 0; } return [ $branch ]; } return []; } function wayfarerRuntimeSelectEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; $validation = wayfarerConditionValidateNode( $conditions ); if ( !$validation['valid'] ) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } function wayfarerRuntimeRollEffectValue( array $branch ): array { $mode = strtolower( trim( (string)( $branch[ 'value_mode' ] ?? 'fixed' ) ) ); if ($mode !== 'random') { $value = $branch['value'] ?? null; if (!is_numeric($value)) { return [ 'valid' => false, 'mode' => 'fixed', 'value' => null, 'min' => null, 'max' => null, 'error' => 'Fixed Effect value must be numeric.', ]; } return [ 'valid' => true, 'mode' => 'fixed', 'value' => wayfarerNormalizeRuntimeNumber( (float)$value ), 'min' => null, 'max' => null, 'error' => '', ]; } $minimum = $branch['value_min'] ?? null; $maximum = $branch['value_max'] ?? null; if ( !is_numeric($minimum) || !is_numeric($maximum) ) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => $minimum, 'max' => $maximum, 'error' => 'Random Effect requires numeric Min and Max values.', ]; } $min = (float)$minimum; $max = (float)$maximum; if ($min > $max) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => 'Random Effect Min cannot be greater than Max.', ]; } if ( floor($min) === $min && floor($max) === $max ) { $rolled = random_int( (int)$min, (int)$max ); } else { /* * Continuous decimal range. Six decimal places of entropy are more * than sufficient for authored simulation values and preserve generic * numeric Min/Max support without requiring authored precision metadata. */ $fraction = random_int( 0, 1000000 ) / 1000000; $rolled = $min + (($max - $min) * $fraction); } return [ 'valid' => true, 'mode' => 'random', 'value' => wayfarerNormalizeRuntimeNumber( (float)$rolled ), 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => '', ]; } function wayfarerApplyRuntimeOneTimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect[ 'target_scope' ] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect[ 'target_type' ] ?? '' ) ) ); $targetId = trim( (string)( $effect[ 'target_id' ] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection[ 'branch_index' ], 'applied' => false, 'error' => $selection[ 'error' ], ]; } if ( !$selection['matched'] ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection[ 'branch_index' ]; $operation = strtolower( trim( (string)( $branch[ 'operation' ] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; if ($operation === 'none') { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => null, 'rolled_value' => null, 'applied' => false, 'error' => '', ]; } $resolved = wayfarerRuntimeRollEffectValue( $branch ); if (!$resolved['valid']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => $resolved[ 'mode' ], 'rolled_value' => null, 'random_min' => $resolved[ 'min' ], 'random_max' => $resolved[ 'max' ], 'applied' => false, 'error' => $resolved[ 'error' ], ]; } $concreteEffect = [ 'target_scope' => $scope, 'target_type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'value' => $resolved[ 'value' ], ]; $report = wayfarerApplyRuntimeEffect( $runtimeState, $concreteEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; $report['value_mode'] = $resolved[ 'mode' ]; $report['rolled_value'] = $resolved[ 'value' ]; if ( $resolved['mode'] === 'random' ) { $report['random_min'] = $resolved[ 'min' ]; $report['random_max'] = $resolved[ 'max' ]; } return $report; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECT_RUNTIME_V42 | | These are event Effects, not Tick Effects. The caller decides that a | Location has been triggered and invokes this function exactly once for that | trigger. Effects execute top-to-bottom against current runtime state. | */ function wayfarerApplyLocationEffects( PDO $pdo, int $gameId, array $location, array &$runtimeState ): array { $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeTraitConfig( $location['config'] ?? null ); $effects = $locationConfig['effects'] ?? []; if (!is_array($effects)) { $effects = []; } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false ) { $regionConfig = wayfarerRuntimeDecodeTraitConfig( $rawRegionConfig ); } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeOneTimeEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_location_effects'] = [ 'location_id' => $locationId, 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), 'reports' => $reports, 'bound_repairs' => $boundRepairs, ]; return $reports; } /* |-------------------------------------------------------------------------- | Current Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECT_RUNTIME_V35 | | Linear Regions resolve a current Location by exact authored Position, | matching the player-scene resolver. Location Tick Effects run after Region | Tick Effects, so movement performed by the Region can cause arrival effects | at the newly reached Location on the same tick. | */ function wayfarerRuntimeFindCurrentLocation( PDO $pdo, int $gameId, array $runtimeState ): ?array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ( $gameId <= 0 || $regionId <= 0 ) { return null; } $position = $runtimeState['navigation']['position'] ?? null; if (!is_numeric($position)) { return null; } $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($locations as $location) { $config = []; if ( !empty( $location['config'] ) ) { $decoded = json_decode( (string)$location['config'], true ); if (is_array($decoded)) { $config = $decoded; } } if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } if ( (float)$config['position'] === (float)$position ) { $location['_runtime_config'] = $config; /* * WAYFARER_LOCATION_CONDITIONS_CURRENT_V45 */ if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } return $location; } } return null; } function wayfarerApplyCurrentLocationTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } /* * Being between authored Locations is normal. Record an empty result * rather than treating the absence of a Location as an error. */ if (!$location) { $runtimeState['runtime']['last_location_id'] = null; $runtimeState['runtime']['last_location_tick_effects'] = []; return []; } $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : []; $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); /* * Location effects may target Current Region Traits, so load the active * Region's Trait definitions rather than the Location's authored Traits. */ $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false && $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $locationEffects = $locationConfig['tick_effects'] ?? []; if (!is_array($locationEffects)) { $locationEffects = []; } $reports = []; foreach ( $locationEffects as $effect ) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } $runtimeState['runtime']['last_location_id'] = $locationId; $runtimeState['runtime']['last_location_tick_effects'] = $reports; $runtimeState['runtime']['last_location_trait_bound_repairs'] = $boundRepairs; return $reports; } function wayfarerApplyCurrentRegionTickEffects( PDO $pdo, array &$runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ($regionId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active navigation region.', ] ]; /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Reports are returned for the current request, not persisted. */ return $reports; } $stmt = $pdo->prepare( " SELECT id, game_id, config FROM wf_regions WHERE id = ? LIMIT 1 " ); $stmt->execute([ $regionId ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { $reports = [ [ 'applied' => false, 'error' => 'Active Region ' . $regionId . ' was not found.', ] ]; /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Reports are returned for the current request, not persisted. */ return $reports; } $config = []; if (!empty($region['config'])) { $decoded = json_decode( $region['config'], true ); if (is_array($decoded)) { $config = $decoded; } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, (int)( $region['game_id'] ?? 0 ), $config ); /* * Enforce the invariant before any branch condition is evaluated. * This also repairs state produced by older runtime versions. */ $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $regionId ); $effects = $config['tick_effects'] ?? []; if (!is_array($effects)) { $effects = []; } $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed tick effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $regionId, $traitDefinitions ); } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Tick reports and bound-repair reports are immediate diagnostics only. */ return $reports; } ================================================================================ FILE: update-backups/v56-20260925-202033/play/includes/runtime.php ================================================================================ prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? ORDER BY id " ); $stmt->execute([ $gameId ]); $regions = $stmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($regions as $region) { $regionId = (int)( $region['id'] ?? 0 ); if ($regionId <= 0) { continue; } $regionKey = (string)$regionId; $config = wayfarerRuntimeDecodeConfig( $region['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset( $runtimeState['regions'][$regionKey] ) || !is_array( $runtimeState['regions'][$regionKey] ) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset( $runtimeState['regions'][$regionKey]['traits'] ) || !is_array( $runtimeState['regions'][$regionKey]['traits'] ) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['regions'][$regionKey]['traits'] ) ) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $initialValue; } } } function wayfarerRuntimeLoadStartingRegion( PDO $pdo, int $gameId, ?array $gameConfig = null ): ?array { if ($gameId <= 0) { return null; } if ($gameConfig === null) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { return null; } $gameConfig = wayfarerRuntimeDecodeConfig( $rawConfig ); } $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT * FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $startingRegionId, $gameId, ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); return $region ?: null; } function wayfarerRuntimeInitializeGame( PDO $pdo, int $gameUserId, array $setupInput = [] ): array { if ($gameUserId <= 0) { throw new RuntimeException( 'A valid game-user ID is required to initialize the game.' ); } try { $pdo->beginTransaction(); /* * Lock the playthrough row so two initialize requests cannot create * competing starting states. */ $stmt = $pdo->prepare( " SELECT gu.id, gu.game_id, gu.user_id, gu.role, gu.state, g.config AS game_config, g.player_slots AS player_slots FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id WHERE gu.id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } /* * Initialization is intentionally one-way. To restart a development * game, clear wf_game_users.state to NULL first. */ if ( $row['state'] !== null && $row['state'] !== '' ) { throw new RuntimeException( 'This game-user already has runtime state.' ); } $gameId = (int)$row['game_id']; $gameConfig = wayfarerRuntimeDecodeConfig( $row['game_config'] ?? null ); $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { throw new RuntimeException( 'This Game does not have a Starting Region configured.' ); } $startingRegion = wayfarerRuntimeLoadStartingRegion( $pdo, $gameId, $gameConfig ); if (!$startingRegion) { throw new RuntimeException( 'The configured Starting Region does not exist for this Game.' ); } $regionConfig = wayfarerRuntimeDecodeConfig( $startingRegion['config'] ?? null ); $gameTraits = wayfarerRuntimeInitialTraitState( $gameConfig ); $regionTraits = wayfarerRuntimeInitialTraitState( $regionConfig ); $navigation = [ 'region_id' => (int)$startingRegion['id'], ]; /* * Linear travel currently begins at coordinate zero. * Other navigation modes will get their own authored start-state * rules when those modes are implemented. */ if ( strtolower( trim( (string)( $startingRegion['navigation_type'] ?? '' ) ) ) === 'linear' ) { $navigation['position'] = 0; } $regionKey = (string)$startingRegion['id']; $state = [ /* * WAYFARER_RUNTIME_SCHEMA_V55 * Version the persisted playthrough contract explicitly. */ 'schema_version' => 1, 'game' => [ 'traits' => $gameTraits, ], 'navigation' => $navigation, 'regions' => [ $regionKey => [ 'traits' => $regionTraits, ], ], 'runtime' => [ 'tick' => 0, ], ]; /* * WAYFARER_ALL_REGION_STATE_V46 * * Global conditions can inspect Region state before that Region has * ever been visited. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_V49 * * Items and Characters receive canonical per-playthrough state before * any starting setup effects are applied. */ wayfarerRuntimeEnsureEntityState( $pdo, $gameId, $state ); /* * WAYFARER_APPLY_STARTING_CHOICES_V36 * * Validate all player-entered setup data before persisting anything. * Then apply the selected options' starting effects to the canonical * initial runtime state. */ $preparedSetup = wayfarerRuntimePrepareStartingSetup( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $setupInput ); /* * WAYFARER_PLAYER_TRAITS_RUNTIME_V52 * * Persist the validated player-character names before creating the * per-player Trait state. Each slot receives independent values. */ $state['setup'] = [ 'player_names' => $preparedSetup['player_names'], 'choices' => $preparedSetup['choices'], ]; wayfarerRuntimeEnsureGameTraitState( $gameConfig, $state ); wayfarerRuntimeEnsurePlayerState( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $state ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $startingEffectReports = []; foreach ( $preparedSetup['effects'] as $startingEffect ) { $report = wayfarerApplyRuntimeEffect( $state, $startingEffect, (int)$startingRegion['id'], $traitDefinitions ); if ( !empty($report['error']) ) { throw new RuntimeException( 'Unable to apply a Starting Choice effect: ' . $report['error'] ); } $startingEffectReports[] = $report; } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * * Starting-effect reports are intentionally not persisted. The effects * themselves have already mutated canonical state. */ /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 * * A game that begins exactly on an active Location should present that * Location before ordinary Action Mode. */ $startingLocation = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $state ); if ($startingLocation) { /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Starting on a Location is a real Location trigger. Apply its * one-time Effects before opening Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, $gameId, $startingLocation, $state ); $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$startingLocation['id'], 'phase' => 'presentation', 'trigger' => 'initial', 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /* WAYFARER_GAME_ENDINGS_INITIAL_V45 */ wayfarerRuntimeEvaluateGameEndings( $pdo, $gameId, $state ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => $gameId, 'starting_region_id' => (int)$startingRegion['id'], 'state' => $state, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Resolve a game-user record for the tick prototype. * * Preferred: * explicit wf_game_users.id * * Temporary development fallback: * first wf_game_users record. * * The fallback exists only because Wayfarer has not yet wired the play * page to authentication / game selection. Once that exists, remove the * fallback and always pass the active game_user_id explicitly. */ function wayfarerRuntimeResolveGameUserId( PDO $pdo, ?int $requestedId = null ): int { if ($requestedId !== null && $requestedId > 0) { $stmt = $pdo->prepare( " SELECT id FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $requestedId ]); $id = $stmt->fetchColumn(); if ($id !== false) { return (int)$id; } throw new RuntimeException( 'The requested game-user record does not exist.' ); } $id = $pdo ->query( " SELECT id FROM wf_game_users ORDER BY id LIMIT 1 " ) ->fetchColumn(); if ($id === false) { throw new RuntimeException( 'No wf_game_users record exists.' ); } return (int)$id; } /** * Load one game-user runtime state. */ function wayfarerRuntimeLoad( PDO $pdo, int $gameUserId ): array { $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'state' => $state, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /** * Advance exactly one tick. * * This is intentionally the entire v14 game engine: * * state.runtime.tick += 1 * * The transaction and row lock are already in place so later work can add * movement/effects without changing the request architecture. */ /* |-------------------------------------------------------------------------- | Linear Location crossing | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | A tick may move farther than the distance to the next authored Location. | This resolver finds the FIRST active Location crossed in the direction of | travel. The movement is then clamped to that exact authored coordinate. | | Examples: | 45 -> 55, Location 50 => stop at 50 | 55 -> 45, Location 50 => stop at 50 | | The starting coordinate is intentionally exclusive so leaving a Location | does not immediately trigger that same Location again. | */ /* |-------------------------------------------------------------------------- | Location Conditions | WAYFARER_LOCATION_CONDITIONS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeLocationConditionsPass( PDO $pdo, int $gameId, array $location, array &$runtimeState ): bool { $config = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $conditions = $config['conditions'] ?? null; $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; return wayfarerConditionsPass( $conditions, $resolver ); } /* |-------------------------------------------------------------------------- | Game Endings | WAYFARER_GAME_ENDINGS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeEvaluateGameEndings( PDO $pdo, int $gameId, array &$runtimeState ): ?array { /* * WAYFARER_ALL_REGION_STATE_ENDINGS_V46 */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $runtimeState ); if ( isset( $runtimeState['runtime']['ending'] ) && is_array( $runtimeState['runtime']['ending'] ) ) { return $runtimeState['runtime']['ending']; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); $config = wayfarerRuntimeDecodeConfig( $raw === false ? null : $raw ); $endings = $config['endings'] ?? []; if (!is_array($endings)) { return null; } $ordered = []; foreach ( array_values($endings) as $index => $ending ) { if (!is_array($ending)) { continue; } $ending['_runtime_order'] = $index; $ordered[] = $ending; } usort( $ordered, function ( array $a, array $b ): int { $priorityCompare = (int)( $b['priority'] ?? 0 ) <=> (int)( $a['priority'] ?? 0 ); if ($priorityCompare !== 0) { return $priorityCompare; } return (int)( $a['_runtime_order'] ?? 0 ) <=> (int)( $b['_runtime_order'] ?? 0 ); } ); $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; foreach ($ordered as $ending) { if ( !wayfarerConditionsPass( $ending['conditions'] ?? null, $resolver ) ) { continue; } $terminal = [ 'id' => (string)( $ending['id'] ?? '' ), 'title' => (string)( $ending['title'] ?? 'The End' ), 'text' => (string)( $ending['text'] ?? '' ), 'image_media_id' => (int)( $ending['image_media_id'] ?? 0 ), 'priority' => (int)( $ending['priority'] ?? 0 ), 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), ]; if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['ending'] = $terminal; unset( $runtimeState['runtime']['presentation'] ); return $terminal; } return null; } function wayfarerRuntimeFindCrossedLocation( PDO $pdo, int $gameId, int $regionId, $fromPosition, $toPosition, array &$runtimeState ): ?array { /* * WAYFARER_CROSSED_LOCATION_STATE_FIX_V46_1 * * Location Conditions are evaluated against the live runtime state. */ if ( $gameId <= 0 || $regionId <= 0 || !is_numeric($fromPosition) || !is_numeric($toPosition) ) { return null; } $from = (float)$fromPosition; $to = (float)$toPosition; if ($from === $to) { return null; } /* * Crossing interception currently applies only to Linear Regions. */ $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); $forward = $to > $from; $candidate = null; $candidatePosition = null; foreach ($locations as $location) { $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $locationPosition = (float)$config['position']; /* * The starting point is exclusive; the destination is inclusive. */ $crossed = $forward ? ( $locationPosition > $from && $locationPosition <= $to ) : ( $locationPosition < $from && $locationPosition >= $to ); if (!$crossed) { continue; } $location['_runtime_config'] = $config; if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } if ($candidate === null) { $candidate = $location; $candidatePosition = $locationPosition; } else { $nearer = $forward ? ( $locationPosition < $candidatePosition ) : ( $locationPosition > $candidatePosition ); $samePositionLowerId = $locationPosition === $candidatePosition && (int)$location['id'] < (int)$candidate['id']; if ( $nearer || $samePositionLowerId ) { $candidate = $location; $candidatePosition = $locationPosition; } } } if ($candidate === null) { return null; } $candidate['_runtime_position'] = wayfarerNormalizeRuntimeNumber( (float)$candidatePosition ); $candidate['_runtime_config'] = wayfarerRuntimeDecodeConfig( $candidate['config'] ?? null ); return $candidate; } /* |-------------------------------------------------------------------------- | Player spatial context |-------------------------------------------------------------------------- | WAYFARER_PLAYER_CONTEXT_V46_1 | | This is the canonical player-facing spatial label for Action Mode: | | exact eligible Location -> Location name + Region parent context | otherwise -> Region name | | It is returned by runtime APIs so the browser does not keep displaying the | Location name from the page's original server render after movement. | */ function wayfarerRuntimePlayerContext( PDO $pdo, int $gameId, array $runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionName = ''; if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $rawRegionName = $stmt->fetchColumn(); if ($rawRegionName !== false) { $regionName = trim( (string)$rawRegionName ); } } $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); $locationId = 0; $locationName = ''; if ($location) { $locationId = (int)( $location['id'] ?? 0 ); $locationName = trim( (string)( $location['name'] ?? '' ) ); } $contextName = $locationName !== '' ? $locationName : ( $regionName !== '' ? $regionName : 'Actions' ); return [ 'region_id' => $regionId, 'region_name' => $regionName, 'location_id' => $locationId, 'location_name' => $locationName, 'context_name' => $contextName, 'parent_context' => $locationName !== '' ? $regionName : '', ]; } /* |-------------------------------------------------------------------------- | Presentation dismissal |-------------------------------------------------------------------------- | | Presentation is persisted runtime state. This function is intentionally | generic even though v40 renders only Location presentations; Storylets can | use the same mechanism later. | */ function wayfarerRuntimeDismissPresentation( PDO $pdo, int $gameUserId, string $expectedType = '', int $expectedId = 0 ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); $presentation = $state['runtime']['presentation'] ?? null; if ( !is_array($presentation) || empty($presentation['type']) || empty($presentation['id']) ) { throw new RuntimeException( 'There is no active presentation to continue.' ); } if ( $expectedType !== '' && (string)$presentation['type'] !== $expectedType ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } if ( $expectedId > 0 && (int)$presentation['id'] !== $expectedId ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } $state['runtime']['last_presentation'] = $presentation; unset( $state['runtime']['presentation'] ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'dismissed' => $presentation, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Advance exactly one tick. * * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ function wayfarerRuntimeAdvanceTick( PDO $pdo, int $gameUserId ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } /* * WAYFARER_GAME_ENDINGS_TERMINAL_V45 * * Endings are terminal runtime state. */ if ( isset( $state['runtime']['ending'] ) && is_array( $state['runtime']['ending'] ) ) { throw new RuntimeException( 'This game has ended.' ); } /* * Narrative presentation is a true runtime pause, not merely a hidden * browser control. Even Author Mode cannot advance ticks through it. */ if ( isset( $state['runtime']['presentation'] ) && is_array( $state['runtime']['presentation'] ) ) { throw new RuntimeException( 'Continue the current presentation before advancing time.' ); } /* * WAYFARER_ALL_REGION_STATE_TICK_V46 * * Recovery path for playthroughs initialized before v46, and for * Regions/Traits authored after a playthrough began. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, (int)$row['game_id'], $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_TICK_V49 * * Recovery path for playthroughs created before V49 and for Entities * or Traits authored after play began. */ wayfarerRuntimeEnsureEntityState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_PLAYER_TRAITS_TICK_RECOVERY_V52 */ wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$row['game_id'], $state ); $regionIdBeforeTick = (int)( $state['navigation']['region_id'] ?? 0 ); $positionBeforeTick = $state['navigation']['position'] ?? null; $currentTick = (int)( $state['runtime']['tick'] ?? 0 ); $state['runtime']['tick'] = $currentTick + 1; /* * Global Game Tick Effects run first. */ wayfarerApplyGameTickEffects( $pdo, (int)$row['game_id'], $state ); /* * Current Region Tick Effects then perform Region-level movement and * state changes. */ wayfarerApplyCurrentRegionTickEffects( $pdo, $state ); /* * Resolve whether the completed movement would have crossed an active * Location. If so, stop at the nearest crossed Location rather than * accepting the overshot position. */ $regionIdAfterMovement = (int)( $state['navigation']['region_id'] ?? 0 ); $positionAfterMovement = $state['navigation']['position'] ?? null; $crossedLocation = null; /* * We only interpret movement as a continuous path while the player * remains in the same Region. Region transitions will later get their * own explicit navigation semantics. */ if ( $regionIdBeforeTick > 0 && $regionIdBeforeTick === $regionIdAfterMovement ) { $crossedLocation = wayfarerRuntimeFindCrossedLocation( $pdo, (int)$row['game_id'], $regionIdAfterMovement, $positionBeforeTick, $positionAfterMovement, $state ); } $arrivalPosition = null; if ($crossedLocation) { $arrivalPosition = $crossedLocation[ '_runtime_position' ]; $state['navigation']['position'] = $arrivalPosition; /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Event Effects execute once at the trigger boundary. They run * before the Location's Tick Effects and before Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, (int)$row['game_id'], $crossedLocation, $state ); } /* * WAYFARER_LOCATION_EFFECTS_ONLY_RUNTIME_V43 * * Locations no longer own Tick Effects. Game and Region Tick Effects * have already advanced the simulation; a crossed Location contributes * its one-time Effects and then interrupts with Presentation Mode. */ if ($crossedLocation) { $state['navigation']['position'] = $arrivalPosition; $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$crossedLocation['id'], 'phase' => 'presentation', 'trigger' => 'arrival', 'tick' => (int)$state['runtime']['tick'], ]; $state['runtime']['last_location_arrival'] = [ 'location_id' => (int)$crossedLocation['id'], 'region_id' => $regionIdAfterMovement, 'position' => $arrivalPosition, 'tick' => (int)$state['runtime']['tick'], ]; } /* WAYFARER_GAME_ENDINGS_AFTER_TICK_V45 */ $ending = wayfarerRuntimeEvaluateGameEndings( $pdo, (int)$row['game_id'], $state ); $state['runtime']['last_tick_at'] = gmdate('c'); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId ]); $pdo->commit(); $presentation = $state['runtime']['presentation'] ?? null; $pausedForPresentation = is_array($presentation) && !empty($presentation['type']) && !empty($presentation['id']); $ending = $state['runtime']['ending'] ?? null; $ended = is_array($ending) && !empty($ending['id']); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'tick' => (int)$state['runtime']['tick'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'pause' => $pausedForPresentation || $ended, 'pause_reason' => $ended ? 'Game ended.' : ( $pausedForPresentation ? 'Location reached.' : '' ), 'presentation' => $pausedForPresentation ? $presentation : null, 'ending' => $ended ? $ending : null, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: update-backups/v56-20260925-202033/play/includes/setup.php ================================================================================ 0; } elseif ($selector === 'slot') { $slot = filter_var( $effect['target_slot'] ?? null, FILTER_VALIDATE_INT ); $validEffect = $slot !== false && $slot >= 0 && $slot < $playerSlots; if ($validEffect) { $effect['target_slot'] = (int)$slot; } } else { $validEffect = false; } } /* * WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 * * Item Quantity Starting Effects store the Entity identity in the * scope (entity:ID) and retain the canonical system-key marker. */ if ( !$validEffect && $effectType === 'trait' && $effectTargetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $effectScope ) === 1 && ( (string)( $effect['target_system_key'] ?? '' ) === 'quantity' || $effectTargetId === 'sys_item_quantity' ) ) { $validEffect = true; } if (!$validEffect) { throw new RuntimeException( 'A Starting Choice contains an unsupported effect target.' ); } $effects[] = $effect; } } return [ 'player_names' => $playerNames, 'choices' => $selections, 'effects' => $effects, ]; } ================================================================================ FILE: update-backups/v57-20260925-202547/admin/builder.php ================================================================================ prepare(" SELECT id, name, config FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; /* WAYFARER_STORYLET_BUILDER_V56 * A + New Branch Storylet link can preselect its parent and choose a sensible * sibling order without making parent_storylet_id part of the generic request. */ if ( $type === 'storylets' && !$id && !empty($_GET['parent_storylet_id']) ) { $requestedParentId = (int)$_GET['parent_storylet_id']; $parentStmt = $pdo->prepare( " SELECT id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $parentStmt->execute([ $requestedParentId, $gameId, ]); if ($parentStmt->fetchColumn()) { $record['parent_storylet_id'] = $requestedParentId; $orderStmt = $pdo->prepare( " SELECT COALESCE(MAX(branch_sort_order), 0) FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? " ); $orderStmt->execute([ $gameId, $requestedParentId, ]); $record['branch_sort_order'] = (int)$orderStmt->fetchColumn() + 10; } } // ================================================================ // SAVE / DELETE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { /* * Media Library associations are applied only after the authored * object has saved successfully and therefore has a stable ID. */ $savedRecordId = (int)$saveResult['id']; $mediaResult = applyBuilderMediaSelections( $pdo, $gameId, $definition['table'], $savedRecordId, $_POST, $_FILES ); if (!$mediaResult['saved']) { /* * The authored object itself has already saved. Keep the * editor on that record and report the media failure * accurately rather than pretending the object save failed. */ $id = $savedRecordId; $record['id'] = $savedRecordId; $error = 'Record saved, but media was not changed: ' . $mediaResult['error']; } else { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) . '&id=' . (int)$saveResult['id'] . '&tab=' . rawurlencode( (string)($_POST['active_tab'] ?? 'basics') ) ); exit; } } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder

================================================================================ FILE: update-backups/v57-20260925-202547/admin/games/edit.php ================================================================================ null, 'name' => '', 'slug' => '', 'description' => '', 'instructions' => '', 'player_slots' => 0, 'config' => null, 'status' => 'draft' ]; $traits = []; $playerTraits = []; $config = []; /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ $gameTickEffects = []; $gameplayTickEffects = []; $startingChoices = []; $startingChoicesEditor = []; $startingChoiceItemTargets = []; /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 */ $gameEndings = []; $gameEndingsEditor = []; $error = ''; // ================================================================ // LOAD EXISTING GAME // ================================================================ if (isset($_GET['id'])) { $stmt = $pdo->prepare(" SELECT * FROM wf_games WHERE id = ? "); $stmt->execute([(int)$_GET['id']]); $existingGame = $stmt->fetch(); if (!$existingGame) { die('Game not found.'); } $game = array_merge($game, $existingGame); if (!empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } if ( isset($config['traits']) && is_array($config['traits']) ) { $traits = $config['traits']; } /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ if ( isset($config['player_traits']) && is_array($config['player_traits']) ) { $playerTraits = array_values( $config['player_traits'] ); } /* WAYFARER_LOAD_GAME_TICK_EFFECTS_V33 */ if ( isset($config['tick_effects']) && is_array($config['tick_effects']) ) { $gameTickEffects = array_values( $config['tick_effects'] ); } if ( isset($config['setup']['choices']) && is_array($config['setup']['choices']) ) { $startingChoices = array_values( $config['setup']['choices'] ); } /* WAYFARER_GAME_ENDINGS_V45 */ if ( isset($config['endings']) && is_array($config['endings']) ) { $gameEndings = array_values( $config['endings'] ); } } $gameplayTickEffects = $gameTickEffects; $startingChoicesEditor = $startingChoices; $gameEndingsEditor = $gameEndings; // ================================================================ // SAVE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $game['id'] = !empty($_POST['id']) ? (int)$_POST['id'] : null; $game['name'] = trim($_POST['name'] ?? ''); $game['slug'] = trim($_POST['slug'] ?? ''); $game['description'] = trim($_POST['description'] ?? ''); $game['instructions'] = trim($_POST['instructions'] ?? ''); $game['player_slots'] = max(0, (int)($_POST['player_slots'] ?? 0)); $game['status'] = $_POST['status'] ?? 'draft'; // ------------------------------------------------------------ // BUILD GAME + PLAYER TRAIT DEFINITIONS // WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 // ------------------------------------------------------------ $traitResult = buildTraitsFromPost( $_POST['traits'] ?? [], ['status'] ); $traits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } $playerTraitResult = buildTraitsFromPost( $_POST['player_traits'] ?? [], ['status'] ); $playerTraits = $playerTraitResult['traits']; if ( $error === '' && $playerTraitResult['error'] !== '' ) { $error = 'Player Traits: ' . $playerTraitResult['error']; } /* WAYFARER_GAME_SETTINGS_UNIVERSAL_TARGETS_V56 */ $gameplayRuntimeTargets = gameplayRuntimeStateTargets( $pdo, (int)($game['id'] ?? 0), $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ) ); /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 */ $startingChoiceItemTargets = wayfarerStartingChoiceItemQuantityTargets( $pdo, (int)($game['id'] ?? 0) ); /* WAYFARER_BUILD_STARTING_CHOICES_V36 */ /* WAYFARER_STARTING_CHOICES_CANONICAL_EMPTY_V36_1 */ $startingChoicesSubmitted = isset($_POST['setup_choices_present']) || array_key_exists( 'setup_choices', $_POST ); $postedStartingChoices = isset($_POST['setup_choices']) && is_array($_POST['setup_choices']) ? array_values( $_POST['setup_choices'] ) : []; $startingChoicesEditor = $postedStartingChoices; if ($error === '') { $startingChoiceResult = wayfarerBuildStartingChoicesFromPost( $postedStartingChoices, $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ), $startingChoiceItemTargets, $gameplayRuntimeTargets ); if ( $startingChoiceResult['error'] !== '' ) { $error = $startingChoiceResult['error']; } else { $startingChoices = $startingChoiceResult['choices']; $startingChoicesEditor = $startingChoices; } } /* WAYFARER_BUILD_GAME_ENDINGS_V45 */ $postedGameEndings = isset($_POST['endings']) && is_array($_POST['endings']) ? array_values( $_POST['endings'] ) : []; $gameEndingsEditor = $postedGameEndings; if ($error === '') { $endingResult = wayfarerBuildGameEndingsFromPost( $pdo, (int)($game['id'] ?? 0), $postedGameEndings, $traits ); if ( $endingResult['error'] !== '' ) { $error = $endingResult['error']; } else { $gameEndings = $endingResult['endings']; $gameEndingsEditor = $gameEndings; } } /* WAYFARER_BUILD_GAME_TICK_EFFECTS_V33 */ $postedGameTickEffects = isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ? array_values( $_POST['tick_effects'] ) : []; $gameplayTickEffects = $postedGameTickEffects; if ($error === '') { $gameTickEffectResult = gameplayBuildTickEffectsFromPost( $gameplayRuntimeTargets, $postedGameTickEffects, $gameplayRuntimeTargets ); if ( $gameTickEffectResult['error'] !== '' ) { $error = $gameTickEffectResult['error']; } else { $gameTickEffects = $gameTickEffectResult['effects']; $gameplayTickEffects = $gameTickEffects; } } // ------------------------------------------------------------ // VALIDATE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['name'] === '') { $error = 'Game name is required.'; } elseif ($game['slug'] === '') { $error = 'Slug is required.'; } } // ------------------------------------------------------------ // BUILD CONFIG JSON // ------------------------------------------------------------ if ($error === '') { /* * Preserve any other configuration already stored for the game. * Traits own only the "traits" section of config. */ if (empty($config) && !empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } $startingRegionId = !empty($_POST['starting_region_id']) ? (int)$_POST['starting_region_id'] : null; if ($startingRegionId !== null) { $config['starting_region_id'] = $startingRegionId; } else { unset($config['starting_region_id']); } $config['traits'] = array_values( $traits ); /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ $config['player_traits'] = array_values( $playerTraits ); /* WAYFARER_SAVE_GAME_TICK_EFFECTS_V33 */ if ($gameTickEffects) { $config['tick_effects'] = array_values( $gameTickEffects ); } else { unset( $config['tick_effects'] ); } /* WAYFARER_SAVE_STARTING_CHOICES_V36 */ /* * WAYFARER_STARTING_CHOICES_CANONICAL_EMPTY_V36_1 * * If the Setup editor participated in this submission, its current * collection is the canonical collection. In particular, deleting the * final card must persist an explicit empty array rather than relying * on the absence of setup_choices in POST. */ if ($startingChoicesSubmitted) { if ( !isset($config['setup']) || !is_array($config['setup']) ) { $config['setup'] = []; } $config['setup']['choices'] = array_values( $startingChoices ); } /* WAYFARER_SAVE_GAME_ENDINGS_V45 */ $config['endings'] = array_values( $gameEndings ); $game['config'] = json_encode( $config, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ($game['config'] === false) { $error = 'Unable to encode game configuration.'; } } // ------------------------------------------------------------ // WRITE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['id']) { $stmt = $pdo->prepare(" UPDATE wf_games SET name = ?, slug = ?, description = ?, instructions = ?, player_slots = ?, config = ?, status = ? WHERE id = ? "); $stmt->execute([ $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'], $game['id'] ]); } else { $uuid = bin2hex(random_bytes(16)); $stmt = $pdo->prepare(" INSERT INTO wf_games ( uuid, name, slug, description, instructions, player_slots, config, status ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $uuid, $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'] ]); $game['id'] = (int)$pdo->lastInsertId(); } /* * WAYFARER_STAY_ON_GAME_SETTINGS_V38 * * Successful saves remain on the same Game Settings record and reopen * the tab the author was using. */ $savedGameTab = (string)( $_POST['active_tab'] ?? 'basics' ); if ( !in_array( $savedGameTab, [ 'basics', 'setup', 'traits', 'gameplay', 'endings', ], true ) ) { $savedGameTab = 'basics'; } header( 'Location: edit.php?id=' . (int)$game['id'] . '&tab=' . rawurlencode( $savedGameTab ) ); exit; } } // ================================================================ // LOAD REGIONS FOR STARTING REGION // ================================================================ $regions = []; if (!empty($game['id'])) { $regionStmt = $pdo->prepare(" SELECT id, name FROM wf_regions WHERE game_id = ? ORDER BY name "); $regionStmt->execute([ $game['id'] ]); $regions = $regionStmt->fetchAll(); } /* WAYFARER_GAME_SETTINGS_UNIVERSAL_TARGETS_RENDER_V56 */ $gameplayRuntimeTargets = gameplayRuntimeStateTargets( $pdo, (int)($game['id'] ?? 0), $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ) ); /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_RENDER_V54 */ $startingChoiceItemTargets = wayfarerStartingChoiceItemQuantityTargets( $pdo, (int)($game['id'] ?? 0) ); ?> <?= $game['id'] ? 'Edit Game' : 'New Game' ?> - Wayfarer


Example: stormgate

================================================================================ FILE: update-backups/v57-20260925-202547/assets/js/builder.js ================================================================================ const wayfarerConditionData = window.wayfarerConditionData || {}; function updateConditionalConfigFields() { document .querySelectorAll('[data-config-field]') .forEach(function (container) { const relationshipName = container.dataset.conditionRelationship; const requiredValue = container.dataset.conditionEquals; if (!relationshipName) { container.style.display = ''; return; } const relationshipSelect = document.querySelector( '[data-relationship="' + relationshipName + '"]' ); if (!relationshipSelect) { container.style.display = 'none'; return; } const selectedId = relationshipSelect.value; const actualValue = wayfarerConditionData[relationshipName]?.[selectedId]; container.style.display = actualValue === requiredValue ? '' : 'none'; }); } document .querySelectorAll('[data-relationship]') .forEach(function (select) { select.addEventListener( 'change', updateConditionalConfigFields ); }); // ================================================================ // SYSTEM TRAIT GROUPS // ================================================================ function updateSystemTraitGroups(container) { const selectorField = container.dataset.selectorField; if (!selectorField) { return; } const selector = document.querySelector( '[name="' + selectorField + '"]' ); if (!selector) { return; } const selectedGroup = selector.value; container .querySelectorAll('[data-system-trait-group]') .forEach(function (group) { const active = group.dataset.systemTraitGroup === selectedGroup; group.style.display = active ? '' : 'none'; group .querySelectorAll('input, select, textarea') .forEach(function (input) { input.disabled = !active; }); }); } document .querySelectorAll('[data-system-trait-settings]') .forEach(function (container) { const selectorField = container.dataset.selectorField; const selector = selectorField ? document.querySelector( '[name="' + selectorField + '"]' ) : null; if (selector) { selector.addEventListener( 'change', function () { updateSystemTraitGroups(container); } ); } updateSystemTraitGroups(container); }); // ================================================================ // COLLECTION SORTING // ================================================================ document .querySelectorAll('[data-sort-column]') .forEach(function (button) { button.addEventListener('click', function () { const table = button.closest('[data-collection-table]'); if (!table) { return; } const tbody = table.querySelector('tbody'); const columnName = button.dataset.sortColumn; const sortType = button.dataset.sortType || 'text'; const currentDirection = button.dataset.sortDirection || 'none'; const nextDirection = currentDirection === 'asc' ? 'desc' : 'asc'; table .querySelectorAll('[data-sort-column]') .forEach(function (otherButton) { otherButton.dataset.sortDirection = 'none'; }); button.dataset.sortDirection = nextDirection; const rows = Array.from(tbody.querySelectorAll('tr')); rows.sort(function (a, b) { const aCell = a.querySelector( '[data-column="' + columnName + '"]' ); const bCell = b.querySelector( '[data-column="' + columnName + '"]' ); const aValue = aCell?.dataset.sortValue ?? ''; const bValue = bCell?.dataset.sortValue ?? ''; let comparison = 0; if (sortType === 'number') { const aNumber = aValue === '' ? Number.POSITIVE_INFINITY : Number(aValue); const bNumber = bValue === '' ? Number.POSITIVE_INFINITY : Number(bValue); comparison = aNumber - bNumber; } else { comparison = aValue.localeCompare( bValue, undefined, { numeric: true, sensitivity: 'base' } ); } return nextDirection === 'asc' ? comparison : -comparison; }); rows.forEach(function (row) { tbody.appendChild(row); }); }); }); updateConditionalConfigFields(); // ================================================================ // MEDIA LIBRARY CONTROLS // ================================================================ function updateBuilderMediaSlot(slotContainer) { const checkedAction = slotContainer.querySelector( 'input[type="radio"][name^="media_action"]:checked' ); const action = checkedAction ? checkedAction.value : 'none'; const existingSelect = slotContainer.querySelector( '[data-media-existing]' ); if (existingSelect) { existingSelect.disabled = action !== 'existing'; } slotContainer .querySelectorAll( '[data-media-upload-fields] input' ) .forEach(function (input) { input.disabled = action !== 'upload'; }); } document .querySelectorAll('[data-media-slot]') .forEach(function (slotContainer) { slotContainer .querySelectorAll( 'input[type="radio"][name^="media_action"]' ) .forEach(function (radio) { radio.addEventListener( 'change', function () { updateBuilderMediaSlot( slotContainer ); } ); }); updateBuilderMediaSlot( slotContainer ); }); // ================================================================ // AUTHORED OBJECT CONTEXT HEADING // WAYFARER_OBJECT_CONTEXT_HEADING_V34 // ================================================================ function initializeBuilderObjectContextHeading() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } const heading = editor.querySelector( '[data-builder-object-heading]' ); if (!heading) { return; } const fieldName = heading.dataset.builderObjectLabelField || 'name'; const form = editor.querySelector( 'form' ); if (!form) { return; } const field = Array .from( form.elements ) .find(function (element) { return ( element && element.name === fieldName ); }); if (!field) { return; } const context = heading.querySelector( '[data-builder-object-context]' ); const label = heading.querySelector( '[data-builder-object-context-label]' ); if (!context || !label) { return; } function refreshContextHeading() { const value = String( field.value || '' ).trim(); label.textContent = value; context.hidden = value === ''; } field.addEventListener( 'input', refreshContextHeading ); field.addEventListener( 'change', refreshContextHeading ); refreshContextHeading(); } initializeBuilderObjectContextHeading(); // ================================================================ // AUTHORED OBJECT EDITOR TABS // ================================================================ function initializeBuilderEditorTabs() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } /* * The editor component has one primary save form. The separate delete * form, when present, lives later in the editor pane and is deliberately * left outside the tab system. */ const form = Array .from(editor.children) .find(function (child) { return child.tagName === 'FORM'; }); if ( !form || form.dataset.builderTabbed === '1' ) { return; } form.dataset.builderTabbed = '1'; form.classList.add( 'builder-object-form' ); /* * WAYFARER_ACTIVE_TAB_V20 * * Persist the selected editor tab through POST/redirect/reload. */ const activeTabInput = document.createElement( 'input' ); activeTabInput.type = 'hidden'; activeTabInput.name = 'active_tab'; const requestedTab = new URLSearchParams( window.location.search ).get( 'tab' ) || 'basics'; activeTabInput.value = requestedTab; form.appendChild( activeTabInput ); const originalChildren = Array.from( form.children ); const panels = { basics: document.createElement('div'), media: document.createElement('div'), traits: document.createElement('div'), /* WAYFARER_CONDITIONS_PANEL_V45_1 */ conditions: document.createElement('div'), tick_effects: document.createElement('div'), /* WAYFARER_STORYLET_BUILDER_V56 */ choices: document.createElement('div') }; panels.basics.className = 'builder-tab-panel'; panels.media.className = 'builder-tab-panel'; panels.traits.className = 'builder-tab-panel'; panels.conditions.className = 'builder-tab-panel'; panels.branches.className = 'builder-tab-panel'; panels.basics.dataset.builderPanel = 'basics'; panels.media.dataset.builderPanel = 'media'; panels.traits.dataset.builderPanel = 'traits'; panels.conditions.dataset.builderPanel = 'conditions'; panels.branches.dataset.builderPanel = 'branches'; panels.tick_effects.className = 'builder-tab-panel'; panels.tick_effects.dataset.builderPanel = 'tick_effects'; let section = 'basics'; let saveContainer = null; let hasMedia = false; let hasTraits = false; let hasConditions = false; let hasTickEffects = false; let hasBranches = false; originalChildren.forEach( function (child) { /* * Keep hidden record identifiers directly in the form. */ if ( child.tagName === 'INPUT' && child.type === 'hidden' ) { return; } /* * Save becomes a stable footer beneath the tab panels. */ const submitButton = child.querySelector ? child.querySelector( 'button[type="submit"], input[type="submit"]' ) : null; if (submitButton) { saveContainer = child; return; } /* * Ignore the separator immediately before a tab heading. * Other HR elements are harmless but unnecessary inside the * cleaned-up editor. */ if (child.tagName === 'HR') { child.remove(); return; } if (child.tagName === 'H3') { const heading = child.textContent .trim() .toLowerCase(); if (heading === 'media') { section = 'media'; hasMedia = true; child.remove(); return; } if (heading === 'traits') { section = 'traits'; hasTraits = true; child.remove(); return; } } /* * WAYFARER_CONDITIONS_PANEL_V45_1 * * Conditions answer whether an authored object is eligible; * Effects answer what changes when it triggers. */ if ( child.hasAttribute && child.hasAttribute( 'data-builder-conditions-panel' ) ) { section = 'conditions'; hasConditions = true; } /* * WAYFARER_ONE_TIME_EFFECTS_EDITOR_V42 * * Location Effects and Tick Effects share one Effects panel. */ if ( child.hasAttribute && ( child.hasAttribute( 'data-builder-effects-panel' ) || child.hasAttribute( 'data-builder-tick-effects' ) ) ) { section = 'tick_effects'; hasTickEffects = true; } /* WAYFARER_STORYLET_BRANCHES_PANEL_V56 */ if ( child.hasAttribute && child.hasAttribute( 'data-builder-branches-panel' ) ) { section = 'branches'; hasBranches = true; } /* * Config fields belong to Basics even though the old v9 Media * markup happens to precede them in the source template. */ if ( child.hasAttribute && child.hasAttribute( 'data-config-field' ) ) { section = 'basics'; } /* * A Media fieldset is definitive even if future markup changes * around the Media heading. */ if ( child.hasAttribute && child.hasAttribute( 'data-media-slot' ) ) { section = 'media'; hasMedia = true; } panels[section] .appendChild( child ); } ); /* * If Traits exist, anything moved after the Traits heading belongs there. * System traits do not use that heading and therefore stay in Basics. */ if ( panels.traits.children.length > 0 ) { hasTraits = true; } if ( panels.media.children.length > 0 ) { hasMedia = true; } if ( panels.conditions.children.length > 0 ) { hasConditions = true; } if ( panels.tick_effects.children.length > 0 ) { hasTickEffects = true; } if ( panels.branches.children.length > 0 ) { hasBranches = true; } const tabBar = document.createElement( 'div' ); tabBar.className = 'builder-editor-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const availablePanels = [ { key: 'basics', label: 'Basics', show: true }, { key: 'media', label: 'Media', show: hasMedia }, { key: 'traits', label: 'Traits', show: hasTraits }, { key: 'conditions', label: 'Conditions', show: hasConditions }, { key: 'tick_effects', label: 'Effects', show: hasTickEffects }, { key: 'branches', label: 'Branches', show: hasBranches } ].filter( function (item) { return item.show; } ); function activatePanel(panelKey) { activeTabInput.value = panelKey; tabBar .querySelectorAll( '[data-builder-tab]' ) .forEach( function (button) { const active = button.dataset.builderTab === panelKey; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); Object .entries(panels) .forEach( function (entry) { const key = entry[0]; const panel = entry[1]; panel.hidden = key !== panelKey; } ); } availablePanels.forEach( function (item) { const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'builder-editor-tab'; button.dataset.builderTab = item.key; button.setAttribute( 'role', 'tab' ); button.textContent = item.label; button.addEventListener( 'click', function () { activatePanel( item.key ); } ); tabBar.appendChild( button ); } ); /* * Insert the new structure after hidden inputs so all form fields remain * part of the same form and submit exactly as before. */ form.appendChild( tabBar ); availablePanels.forEach( function (item) { form.appendChild( panels[item.key] ); } ); if (saveContainer) { saveContainer.classList.add( 'builder-save-bar' ); form.appendChild( saveContainer ); } const initialPanelKey = availablePanels.some( function (item) { return item.key === requestedTab; } ) ? requestedTab : 'basics'; activatePanel( initialPanelKey ); } initializeBuilderEditorTabs(); // ================================================================ // SHARED GAMEPLAY TICK EFFECTS CONTROLS v25 // WAYFARER_GAMEPLAY_MODULAR_V25 // ================================================================ function initializeTickEffects() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } const wrapper = editor.querySelector( '[data-builder-tick-effects]' ); if (!wrapper) { return; } if ( wrapper.dataset.tickEffectsReady === '1' ) { return; } wrapper.dataset.tickEffectsReady = '1'; // ------------------------------------------------------------ // ROW ADD / REMOVE // ------------------------------------------------------------ const list = wrapper.querySelector( '[data-tick-effects-list]' ); const addButton = wrapper.querySelector( '[data-add-tick-effect]' ); const template = wrapper.querySelector( '[data-tick-effect-template]' ); let nextIndex = 0; wrapper .querySelectorAll( '[data-tick-effect-row]' ) .forEach( function (row) { const index = Number( row.dataset.tickIndex ); if ( Number.isFinite(index) && index >= nextIndex ) { nextIndex = index + 1; } } ); function updateLinearTargetAvailability() { const navigationSelect = editor.querySelector( 'select[name="navigation_type"]' ); const navigationType = navigationSelect ? navigationSelect.value : wrapper.dataset.navigationType; const linear = navigationType === 'linear'; wrapper .querySelectorAll( '[data-linear-navigation-target]' ) .forEach( function (option) { option.disabled = !linear; } ); if (!linear) { wrapper .querySelectorAll( '[data-tick-effect-target]' ) .forEach( function (select) { if ( select.value === 'navigation:position' ) { select.value = ''; } } ); } } if ( addButton && list && template ) { addButton.addEventListener( 'click', function () { const html = template.innerHTML.replace( /__INDEX__/g, String( nextIndex++ ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const row = temporary.firstElementChild; if (!row) { return; } list.appendChild( row ); updateLinearTargetAvailability(); } ); } wrapper.addEventListener( 'click', function (event) { const removeButton = event.target.closest( '[data-remove-tick-effect]' ); if (!removeButton) { return; } const row = removeButton.closest( '[data-tick-effect-row]' ); if (row) { row.remove(); } } ); const navigationSelect = editor.querySelector( 'select[name="navigation_type"]' ); if (navigationSelect) { navigationSelect.addEventListener( 'change', updateLinearTargetAvailability ); } updateLinearTargetAvailability(); /* * WAYFARER_EMBEDDED_TICK_EFFECTS_V33 * * Game Settings already owns a Gameplay tab. In embedded mode we keep * this shared editor exactly where PHP rendered it while retaining the * normal Tick Effect, condition, and result-branch controls. */ if ( wrapper.hasAttribute( 'data-tick-effects-embedded' ) ) { return; } // ------------------------------------------------------------ // MOVE THE SECTION INTO ITS OWN BUILDER TAB // ------------------------------------------------------------ const form = wrapper.closest( 'form' ); if (!form) { return; } const tabBar = form.querySelector( '.builder-editor-tabs' ); if (!tabBar) { /* * Builder without the v10 tab layout: * leave the Tick Effects section inline. */ return; } if ( form.querySelector( '[data-builder-panel="tick_effects"]' ) ) { return; } const panel = document.createElement( 'div' ); panel.className = 'builder-tab-panel'; panel.dataset.builderPanel = 'tick_effects'; panel.hidden = true; panel.appendChild( wrapper ); const saveBar = form.querySelector( '.builder-save-bar' ); if (saveBar) { form.insertBefore( panel, saveBar ); } else { form.appendChild( panel ); } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'builder-editor-tab'; button.dataset.builderTab = 'tick_effects'; button.setAttribute( 'role', 'tab' ); button.setAttribute( 'aria-selected', 'false' ); /* WAYFARER_GAMEPLAY_TAB_LABEL_V25_1 */ button.textContent = 'Effects'; tabBar.appendChild( button ); function activateTickEffectsTab() { tabBar .querySelectorAll( '[data-builder-tab]' ) .forEach( function (tabButton) { const active = tabButton === button; tabButton.classList.toggle( 'is-active', active ); tabButton.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); form .querySelectorAll( '[data-builder-panel]' ) .forEach( function (tabPanel) { tabPanel.hidden = tabPanel.dataset.builderPanel !== 'tick_effects'; } ); } button.addEventListener( 'click', activateTickEffectsTab ); /* * The original tab controller does not know about the panel added * afterward, so explicitly hide our panel whenever an original tab * is clicked. */ tabBar .querySelectorAll( '[data-builder-tab]:not([data-builder-tab="tick_effects"])' ) .forEach( function (originalButton) { originalButton.addEventListener( 'click', function () { panel.hidden = true; button.classList.remove( 'is-active' ); button.setAttribute( 'aria-selected', 'false' ); } ); } ); } // ================================================================ // SHARED ONE-TIME EFFECTS CONTROLS // WAYFARER_ONE_TIME_EFFECTS_EDITOR_V42 // WAYFARER_GENERALIZED_EFFECT_EDITOR_V43 // ================================================================ function wayfarerEffectConditionTargets( wrapper ) { const targetJson = wrapper.querySelector( '[data-effect-condition-targets-json]' ); if (!targetJson) { return {}; } try { return JSON.parse( targetJson.textContent || '{}' ); } catch (error) { return {}; } } function wayfarerEffectBuildConditionEditor( host, name, targets ) { const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( name, targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } function wayfarerEffectUpdateResultFields( branch ) { const operation = branch.querySelector( '[data-effect-branch-operation]' ); const modeWrap = branch.querySelector( '[data-effect-value-mode-wrap]' ); const mode = branch.querySelector( '[data-effect-value-mode]' ); const fixedWrap = branch.querySelector( '[data-effect-fixed-wrap]' ); const randomWrap = branch.querySelector( '[data-effect-random-wrap]' ); if (!operation) { return; } const noChange = operation.value === 'none'; if (modeWrap) { modeWrap.hidden = noChange; modeWrap .querySelectorAll( 'select, input' ) .forEach( function (field) { field.disabled = noChange; } ); } const valueMode = mode ? mode.value : 'fixed'; if (fixedWrap) { fixedWrap.hidden = noChange || valueMode !== 'fixed'; fixedWrap .querySelectorAll( 'input' ) .forEach( function (field) { field.disabled = noChange || valueMode !== 'fixed'; } ); } if (randomWrap) { randomWrap.hidden = noChange || valueMode !== 'random'; randomWrap .querySelectorAll( 'input' ) .forEach( function (field) { field.disabled = noChange || valueMode !== 'random'; } ); } } function wayfarerEffectReindexBranches( effectRow ) { const effectIndex = effectRow.dataset.effectIndex; const list = effectRow.querySelector( '[data-effect-branch-list]' ); if ( effectIndex === undefined || !list ) { return; } const branches = Array .from( list.children ) .filter( function (child) { return ( child.matches && child.matches( '[data-effect-branch]' ) ); } ); branches.forEach( function ( branch, branchIndex ) { branch.dataset.branchIndex = String( branchIndex ); branch .querySelectorAll( '[name]' ) .forEach( function (field) { field.name = field.name.replace( /effects\[\d+\]\[branches\]\[\d+\]/, 'effects[' + effectIndex + '][branches][' + branchIndex + ']' ); } ); } ); } function wayfarerEffectRefreshBranchControls( effectRow ) { const list = effectRow.querySelector( '[data-effect-branch-list]' ); if (!list) { return; } const branches = Array .from( list.children ) .filter( function (child) { return ( child.matches && child.matches( '[data-effect-branch]' ) ); } ); const conditionals = branches.filter( function (branch) { return ( branch.dataset.branchKind === 'conditional' ); } ); conditionals.forEach( function ( branch, index ) { const up = branch.querySelector( '[data-move-effect-branch-up]' ); const down = branch.querySelector( '[data-move-effect-branch-down]' ); if (up) { up.disabled = index === 0; } if (down) { down.disabled = index === conditionals.length - 1; } } ); const hasOtherwise = branches.some( function (branch) { return ( branch.dataset.branchKind === 'otherwise' ); } ); const addOtherwise = effectRow.querySelector( '[data-add-effect-otherwise]' ); if (addOtherwise) { addOtherwise.disabled = hasOtherwise; } branches.forEach( wayfarerEffectUpdateResultFields ); wayfarerEffectReindexBranches( effectRow ); } function initializeEffects() { document .querySelectorAll( '[data-builder-effects]' ) .forEach( function (wrapper) { if ( wrapper.dataset.effectsReady === '1' ) { return; } wrapper.dataset.effectsReady = '1'; const list = wrapper.querySelector( '[data-effects-list]' ); const addButton = wrapper.querySelector( '[data-add-effect]' ); const effectTemplate = wrapper.querySelector( '[data-effect-template]' ); const conditionalTemplate = wrapper.querySelector( '[data-effect-conditional-template]' ); const otherwiseTemplate = wrapper.querySelector( '[data-effect-otherwise-template]' ); const conditionTargets = wayfarerEffectConditionTargets( wrapper ); let nextIndex = 0; wrapper .querySelectorAll( '[data-effect-row]' ) .forEach( function (row) { const index = Number( row.dataset.effectIndex ); if ( Number.isFinite( index ) && index >= nextIndex ) { nextIndex = index + 1; } wayfarerEffectRefreshBranchControls( row ); } ); if ( addButton && list && effectTemplate ) { addButton.addEventListener( 'click', function () { const effectIndex = nextIndex++; const html = effectTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', String( effectIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const row = temporary.firstElementChild; if (!row) { return; } list.appendChild( row ); wayfarerEffectRefreshBranchControls( row ); } ); } wrapper.addEventListener( 'change', function (event) { const operation = event.target.closest( '[data-effect-branch-operation]' ); const valueMode = event.target.closest( '[data-effect-value-mode]' ); if ( !operation && !valueMode ) { return; } const branch = event.target.closest( '[data-effect-branch]' ); if (branch) { wayfarerEffectUpdateResultFields( branch ); } } ); wrapper.addEventListener( 'click', function (event) { const effectRow = event.target.closest( '[data-effect-row]' ); const removeEffect = event.target.closest( '[data-remove-effect]' ); if (removeEffect) { if (!effectRow) { return; } if ( !window.confirm( 'Remove this Effect?' ) ) { return; } effectRow.remove(); return; } if (!effectRow) { return; } const addConditional = event.target.closest( '[data-add-effect-conditional]' ); if (addConditional) { if (!conditionalTemplate) { return; } const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if (!branchList) { return; } const effectIndex = effectRow.dataset.effectIndex; const branchIndex = branchList .querySelectorAll( ':scope > [data-effect-branch]' ) .length; const html = conditionalTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } const otherwise = Array .from( branchList.children ) .find( function (child) { return ( child.dataset && child.dataset.branchKind === 'otherwise' ); } ); if (otherwise) { branchList.insertBefore( branch, otherwise ); } else { branchList.appendChild( branch ); } wayfarerEffectReindexBranches( effectRow ); const newIndex = branch.dataset.branchIndex; const host = branch.querySelector( '[data-new-effect-condition-editor]' ); if (host) { wayfarerEffectBuildConditionEditor( host, 'effects[' + effectIndex + '][branches][' + newIndex + '][conditions]', conditionTargets ); } wayfarerEffectRefreshBranchControls( effectRow ); return; } const addOtherwise = event.target.closest( '[data-add-effect-otherwise]' ); if (addOtherwise) { if (!otherwiseTemplate) { return; } const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if ( !branchList || branchList.querySelector( '[data-branch-kind="otherwise"]' ) ) { return; } const effectIndex = effectRow.dataset.effectIndex; const branchIndex = branchList .querySelectorAll( ':scope > [data-effect-branch]' ) .length; const html = otherwiseTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } branchList.appendChild( branch ); wayfarerEffectRefreshBranchControls( effectRow ); return; } const removeBranch = event.target.closest( '[data-remove-effect-branch]' ); if (removeBranch) { const branch = removeBranch.closest( '[data-effect-branch]' ); if (branch) { branch.remove(); wayfarerEffectRefreshBranchControls( effectRow ); } return; } const moveUp = event.target.closest( '[data-move-effect-branch-up]' ); const moveDown = event.target.closest( '[data-move-effect-branch-down]' ); if ( !moveUp && !moveDown ) { return; } const branch = event.target.closest( '[data-effect-branch]' ); const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if ( !branch || !branchList || branch.dataset.branchKind !== 'conditional' ) { return; } const conditionals = Array .from( branchList.children ) .filter( function (child) { return ( child.dataset && child.dataset.branchKind === 'conditional' ); } ); const index = conditionals.indexOf( branch ); if ( moveUp && index > 0 ) { branchList.insertBefore( branch, conditionals[ index - 1 ] ); } if ( moveDown && index >= 0 && index < conditionals.length - 1 ) { const next = conditionals[ index + 1 ]; branchList.insertBefore( next, branch ); } wayfarerEffectRefreshBranchControls( effectRow ); } ); } ); } initializeEffects(); initializeTickEffects();initializeTickEffects(); // ================================================================ // REUSABLE CONDITION EDITOR v27 // WAYFARER_CONDITION_EDITOR_V27 // ================================================================ function wayfarerConditionEscape(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function wayfarerConditionTargetOptions( targets ) { const groups = {}; Object .entries( targets || {} ) .forEach( function (entry) { const key = entry[0]; const target = entry[1] || {}; const group = target.group || 'Targets'; if (!groups[group]) { groups[group] = []; } groups[group].push({ key: key, label: target.label || key }); } ); let html = ''; Object .entries(groups) .forEach( function (entry) { html += ''; entry[1] .forEach( function (target) { html += ''; } ); html += ''; } ); return html; } function wayfarerConditionNextChildIndex( group ) { let maxIndex = -1; const children = Array .from( group.children ) .find( function (child) { return child.hasAttribute( 'data-condition-children' ); } ); if (children) { Array .from( children.children ) .forEach( function (node) { let index = Number( node.dataset.conditionChildIndex ); if (!Number.isFinite(index)) { const namedInput = node.querySelector( '[name]' ); const match = namedInput ? namedInput.name.match( /\[children\]\[(\d+)\]/ ) : null; index = match ? Number(match[1]) : -1; } if ( Number.isFinite(index) && index > maxIndex ) { maxIndex = index; } } ); } return maxIndex + 1; } function wayfarerConditionCreateCondition( name, targets, childIndex ) { const row = document.createElement( 'div' ); row.className = 'builder-condition-row'; row.dataset.conditionNode = '1'; row.dataset.conditionType = 'condition'; row.dataset.conditionChildIndex = String(childIndex); row.innerHTML = '' + '' + '' + '' + ''; return row; } function wayfarerConditionCreateGroup( name, targets, childIndex, isRoot ) { const group = document.createElement( 'div' ); group.className = 'builder-condition-group' + ( isRoot ? ' builder-condition-group-root' : '' ); group.dataset.conditionNode = '1'; group.dataset.conditionType = 'group'; if ( childIndex !== null && childIndex !== undefined ) { group.dataset.conditionChildIndex = String(childIndex); } group.innerHTML = '' + '
' + '' + ' of the following' + ( isRoot ? '' : '' ) + '
' + '
' + '
' + ' ' + '' + '
'; return group; } function wayfarerConditionInitializeEditor( editor, targets ) { if ( !editor || editor.dataset.conditionEditorReady === '1' ) { return; } editor.dataset.conditionEditorReady = '1'; editor.addEventListener( 'click', function (event) { const removeButton = event.target.closest( '[data-remove-condition-node]' ); if (removeButton) { const node = removeButton.closest( '[data-condition-node]' ); if (node) { node.remove(); } return; } const addCondition = event.target.closest( '[data-add-condition]' ); const addGroup = event.target.closest( '[data-add-condition-group]' ); if ( !addCondition && !addGroup ) { return; } const group = event.target.closest( '[data-condition-type="group"]' ); if (!group) { return; } const children = Array .from( group.children ) .find( function (child) { return child.hasAttribute( 'data-condition-children' ); } ); if (!children) { return; } const groupTypeInput = Array .from( group.children ) .find( function (child) { return ( child.tagName === 'INPUT' && child.type === 'hidden' && child.name.endsWith( '[type]' ) ); } ); if (!groupTypeInput) { return; } const groupName = groupTypeInput.name.replace( /\[type\]$/, '' ); const nextIndex = wayfarerConditionNextChildIndex( group ); const childName = groupName + '[children][' + nextIndex + ']'; const newNode = addGroup ? wayfarerConditionCreateGroup( childName, targets, nextIndex, false ) : wayfarerConditionCreateCondition( childName, targets, nextIndex ); children.appendChild( newNode ); } ); } function initializeConditionEditors() { /* * WAYFARER_GENERIC_CONDITION_INIT_V45_2 * * The Condition Editor is shared infrastructure. It must work anywhere it * is rendered, not only when nested inside Tick Effects. * * Server-rendered editors carry their own allowed-target JSON. Dynamic * Tick Effect / Effect editors continue to initialize themselves through * their existing creation paths. */ document .querySelectorAll( '[data-condition-editor]' ) .forEach( function (editor) { let targets = {}; const targetJson = editor.querySelector( '[data-condition-editor-targets-json]' ); if (targetJson) { try { targets = JSON.parse( targetJson.textContent || '{}' ); } catch (error) { targets = {}; } } wayfarerConditionInitializeEditor( editor, targets ); } ); /* * Tick Effects retain their observer because newly-created Tick Effect * rows are added after page load and are not server-rendered editors. */ document .querySelectorAll( '[data-builder-tick-effects]' ) .forEach( function (wrapper) { let targets = {}; const targetJson = wrapper.querySelector( '[data-condition-targets-json]' ); if (targetJson) { try { targets = JSON.parse( targetJson.textContent || '{}' ); } catch (error) { targets = {}; } } wrapper .querySelectorAll( '[data-condition-editor]' ) .forEach( function (editor) { wayfarerConditionInitializeEditor( editor, targets ); } ); /* * New Tick Effect rows are cloned from a template after page * load, so convert their placeholder into a fresh root group. */ const list = wrapper.querySelector( '[data-tick-effects-list]' ); if ( list && !list.dataset.conditionObserverReady ) { list.dataset.conditionObserverReady = '1'; const observer = new MutationObserver( function (mutations) { mutations.forEach( function (mutation) { mutation.addedNodes .forEach( function (addedNode) { if ( !addedNode.querySelectorAll ) { return; } const row = addedNode.matches && addedNode.matches( '[data-tick-effect-row]' ) ? addedNode : addedNode.querySelector( '[data-tick-effect-row]' ); if (!row) { return; } const host = row.querySelector( '[data-new-condition-editor]' ); if (!host) { return; } const index = row.dataset.tickIndex; const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( 'tick_effects[' + index + '][conditions]', targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } ); } ); } ); observer.observe( list, { childList: true, subtree: true } ); } } ); } initializeConditionEditors(); // ================================================================ // TICK EFFECT RESULT BRANCHES v28 // WAYFARER_TICK_EFFECT_BRANCHES_V28 // ================================================================ function wayfarerTickEffectConditionTargets( wrapper ) { const targetJson = wrapper.querySelector( '[data-condition-targets-json]' ); if (!targetJson) { return {}; } try { return JSON.parse( targetJson.textContent || '{}' ); } catch (error) { return {}; } } function wayfarerTickEffectUpdateBranchValue( branch ) { const operation = branch.querySelector( '[data-tick-branch-operation]' ); const valueWrap = branch.querySelector( '[data-tick-branch-value-wrap]' ); if ( !operation || !valueWrap ) { return; } const input = valueWrap.querySelector( 'input' ); const noChange = operation.value === 'none'; valueWrap.hidden = noChange; if (input) { input.disabled = noChange; } } function wayfarerTickEffectReindexBranches( effectRow ) { const effectIndex = effectRow.dataset.tickIndex; const list = effectRow.querySelector( '[data-tick-branch-list]' ); if ( effectIndex === undefined || !list ) { return; } const branches = Array.from( list.children ).filter( function (child) { return ( child.matches && child.matches( '[data-tick-effect-branch]' ) ); } ); branches.forEach( function ( branch, branchIndex ) { branch.dataset.branchIndex = String( branchIndex ); branch .querySelectorAll( '[name]' ) .forEach( function (field) { field.name = field.name.replace( /tick_effects\[\d+\]\[branches\]\[\d+\]/, 'tick_effects[' + effectIndex + '][branches][' + branchIndex + ']' ); } ); } ); } function wayfarerTickEffectRefreshBranchControls( effectRow ) { const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } const branches = Array.from( list.children ).filter( function (child) { return ( child.matches && child.matches( '[data-tick-effect-branch]' ) ); } ); const conditionalBranches = branches.filter( function (branch) { return ( branch.dataset.branchKind === 'conditional' ); } ); conditionalBranches.forEach( function ( branch, index ) { const up = branch.querySelector( '[data-move-tick-branch-up]' ); const down = branch.querySelector( '[data-move-tick-branch-down]' ); if (up) { up.disabled = index === 0; } if (down) { down.disabled = index === conditionalBranches.length - 1; } } ); const hasOtherwise = branches.some( function (branch) { return ( branch.dataset.branchKind === 'otherwise' ); } ); const addOtherwise = effectRow.querySelector( '[data-add-tick-otherwise-result]' ); if (addOtherwise) { addOtherwise.disabled = hasOtherwise; } branches.forEach( wayfarerTickEffectUpdateBranchValue ); wayfarerTickEffectReindexBranches( effectRow ); } function wayfarerTickEffectBuildConditionEditor( host, name, targets ) { const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( name, targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } function initializeTickEffectResultBranches() { document .querySelectorAll( '[data-builder-tick-effects]' ) .forEach( function (wrapper) { if ( wrapper.dataset.tickBranchesReady === '1' ) { return; } wrapper.dataset.tickBranchesReady = '1'; const conditionalTemplate = wrapper.querySelector( '[data-tick-conditional-branch-template]' ); const otherwiseTemplate = wrapper.querySelector( '[data-tick-otherwise-branch-template]' ); const conditionTargets = wayfarerTickEffectConditionTargets( wrapper ); wrapper .querySelectorAll( '[data-tick-effect-row]' ) .forEach( wayfarerTickEffectRefreshBranchControls ); wrapper.addEventListener( 'change', function (event) { const operation = event.target.closest( '[data-tick-branch-operation]' ); if (!operation) { return; } const branch = operation.closest( '[data-tick-effect-branch]' ); if (branch) { wayfarerTickEffectUpdateBranchValue( branch ); } } ); wrapper.addEventListener( 'click', function (event) { const effectRow = event.target.closest( '[data-tick-effect-row]' ); if (!effectRow) { return; } const addConditional = event.target.closest( '[data-add-tick-conditional-result]' ); if (addConditional) { if (!conditionalTemplate) { return; } const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } const effectIndex = effectRow.dataset.tickIndex; const branchIndex = list.querySelectorAll( ':scope > [data-tick-effect-branch]' ).length; const html = conditionalTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } const otherwise = Array .from( list.children ) .find( function (child) { return ( child.dataset && child.dataset.branchKind === 'otherwise' ); } ); if (otherwise) { list.insertBefore( branch, otherwise ); } else { list.appendChild( branch ); } wayfarerTickEffectReindexBranches( effectRow ); const newIndex = branch.dataset.branchIndex; const host = branch.querySelector( '[data-new-branch-condition-editor]' ); if (host) { wayfarerTickEffectBuildConditionEditor( host, 'tick_effects[' + effectIndex + '][branches][' + newIndex + '][conditions]', conditionTargets ); } wayfarerTickEffectRefreshBranchControls( effectRow ); return; } const addOtherwise = event.target.closest( '[data-add-tick-otherwise-result]' ); if (addOtherwise) { if (!otherwiseTemplate) { return; } const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } if ( list.querySelector( '[data-branch-kind="otherwise"]' ) ) { return; } const effectIndex = effectRow.dataset.tickIndex; const branchIndex = list.querySelectorAll( ':scope > [data-tick-effect-branch]' ).length; const html = otherwiseTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } list.appendChild( branch ); wayfarerTickEffectRefreshBranchControls( effectRow ); return; } const removeBranch = event.target.closest( '[data-remove-tick-branch]' ); if (removeBranch) { const branch = removeBranch.closest( '[data-tick-effect-branch]' ); if (branch) { branch.remove(); wayfarerTickEffectRefreshBranchControls( effectRow ); } return; } const moveUp = event.target.closest( '[data-move-tick-branch-up]' ); const moveDown = event.target.closest( '[data-move-tick-branch-down]' ); if ( !moveUp && !moveDown ) { return; } const branch = event.target.closest( '[data-tick-effect-branch]' ); const list = effectRow.querySelector( '[data-tick-branch-list]' ); if ( !branch || !list || branch.dataset.branchKind !== 'conditional' ) { return; } const conditionals = Array.from( list.children ).filter( function (child) { return ( child.dataset && child.dataset.branchKind === 'conditional' ); } ); const index = conditionals.indexOf( branch ); if ( moveUp && index > 0 ) { list.insertBefore( branch, conditionals[index - 1] ); } if ( moveDown && index >= 0 && index < conditionals.length - 1 ) { const next = conditionals[index + 1]; list.insertBefore( next, branch ); } wayfarerTickEffectRefreshBranchControls( effectRow ); } ); /* * Existing Tick Effect code creates whole effect rows from its * template. Refresh branch controls after those rows appear. */ const list = wrapper.querySelector( '[data-tick-effects-list]' ); if (list) { const observer = new MutationObserver( function (mutations) { mutations.forEach( function (mutation) { mutation.addedNodes .forEach( function (node) { if ( !node.matches || !node.matches( '[data-tick-effect-row]' ) ) { return; } wayfarerTickEffectRefreshBranchControls( node ); } ); } ); } ); observer.observe( list, { childList: true } ); } } ); } initializeTickEffectResultBranches(); ================================================================================ FILE: update-backups/v58-20260925-205136/admin/builder.php ================================================================================ prepare(" SELECT id, name, config FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; /* WAYFARER_STORYLET_BUILDER_V56 * A + New Branch Storylet link can preselect its parent and choose a sensible * sibling order without making parent_storylet_id part of the generic request. */ if ( $type === 'storylets' && !$id && !empty($_GET['parent_storylet_id']) ) { $requestedParentId = (int)$_GET['parent_storylet_id']; $parentStmt = $pdo->prepare( " SELECT id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $parentStmt->execute([ $requestedParentId, $gameId, ]); if ($parentStmt->fetchColumn()) { $record['parent_storylet_id'] = $requestedParentId; $orderStmt = $pdo->prepare( " SELECT COALESCE(MAX(branch_sort_order), 0) FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? " ); $orderStmt->execute([ $gameId, $requestedParentId, ]); $record['branch_sort_order'] = (int)$orderStmt->fetchColumn() + 10; } } // ================================================================ // SAVE / DELETE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { /* * Media Library associations are applied only after the authored * object has saved successfully and therefore has a stable ID. */ $savedRecordId = (int)$saveResult['id']; $mediaResult = applyBuilderMediaSelections( $pdo, $gameId, $definition['table'], $savedRecordId, $_POST, $_FILES ); if (!$mediaResult['saved']) { /* * The authored object itself has already saved. Keep the * editor on that record and report the media failure * accurately rather than pretending the object save failed. */ $id = $savedRecordId; $record['id'] = $savedRecordId; $error = 'Record saved, but media was not changed: ' . $mediaResult['error']; } else { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) . '&id=' . (int)$saveResult['id'] . '&tab=' . rawurlencode( (string)($_POST['active_tab'] ?? 'basics') ) ); exit; } } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder

================================================================================ FILE: update-backups/v58-20260925-205136/admin/games/edit.php ================================================================================ null, 'name' => '', 'slug' => '', 'description' => '', 'instructions' => '', 'player_slots' => 0, 'config' => null, 'status' => 'draft' ]; $traits = []; $playerTraits = []; $config = []; /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ $gameTickEffects = []; $gameplayTickEffects = []; $startingChoices = []; $startingChoicesEditor = []; $startingChoiceItemTargets = []; /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 */ $gameEndings = []; $gameEndingsEditor = []; $error = ''; // ================================================================ // LOAD EXISTING GAME // ================================================================ if (isset($_GET['id'])) { $stmt = $pdo->prepare(" SELECT * FROM wf_games WHERE id = ? "); $stmt->execute([(int)$_GET['id']]); $existingGame = $stmt->fetch(); if (!$existingGame) { die('Game not found.'); } $game = array_merge($game, $existingGame); if (!empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } if ( isset($config['traits']) && is_array($config['traits']) ) { $traits = $config['traits']; } /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ if ( isset($config['player_traits']) && is_array($config['player_traits']) ) { $playerTraits = array_values( $config['player_traits'] ); } /* WAYFARER_LOAD_GAME_TICK_EFFECTS_V33 */ if ( isset($config['tick_effects']) && is_array($config['tick_effects']) ) { $gameTickEffects = array_values( $config['tick_effects'] ); } if ( isset($config['setup']['choices']) && is_array($config['setup']['choices']) ) { $startingChoices = array_values( $config['setup']['choices'] ); } /* WAYFARER_GAME_ENDINGS_V45 */ if ( isset($config['endings']) && is_array($config['endings']) ) { $gameEndings = array_values( $config['endings'] ); } } $gameplayTickEffects = $gameTickEffects; $startingChoicesEditor = $startingChoices; $gameEndingsEditor = $gameEndings; // ================================================================ // SAVE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $game['id'] = !empty($_POST['id']) ? (int)$_POST['id'] : null; $game['name'] = trim($_POST['name'] ?? ''); $game['slug'] = trim($_POST['slug'] ?? ''); $game['description'] = trim($_POST['description'] ?? ''); $game['instructions'] = trim($_POST['instructions'] ?? ''); $game['player_slots'] = max(0, (int)($_POST['player_slots'] ?? 0)); $game['status'] = $_POST['status'] ?? 'draft'; // ------------------------------------------------------------ // BUILD GAME + PLAYER TRAIT DEFINITIONS // WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 // ------------------------------------------------------------ $traitResult = buildTraitsFromPost( $_POST['traits'] ?? [], ['status'] ); $traits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } $playerTraitResult = buildTraitsFromPost( $_POST['player_traits'] ?? [], ['status'] ); $playerTraits = $playerTraitResult['traits']; if ( $error === '' && $playerTraitResult['error'] !== '' ) { $error = 'Player Traits: ' . $playerTraitResult['error']; } /* WAYFARER_GAME_SETTINGS_UNIVERSAL_TARGETS_V56 */ $gameplayRuntimeTargets = gameplayRuntimeStateTargets( $pdo, (int)($game['id'] ?? 0), $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ) ); /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 */ $startingChoiceItemTargets = wayfarerStartingChoiceItemQuantityTargets( $pdo, (int)($game['id'] ?? 0) ); /* WAYFARER_BUILD_STARTING_CHOICES_V36 */ /* WAYFARER_STARTING_CHOICES_CANONICAL_EMPTY_V36_1 */ $startingChoicesSubmitted = isset($_POST['setup_choices_present']) || array_key_exists( 'setup_choices', $_POST ); $postedStartingChoices = isset($_POST['setup_choices']) && is_array($_POST['setup_choices']) ? array_values( $_POST['setup_choices'] ) : []; $startingChoicesEditor = $postedStartingChoices; if ($error === '') { $startingChoiceResult = wayfarerBuildStartingChoicesFromPost( $postedStartingChoices, $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ), $startingChoiceItemTargets, $gameplayRuntimeTargets ); if ( $startingChoiceResult['error'] !== '' ) { $error = $startingChoiceResult['error']; } else { $startingChoices = $startingChoiceResult['choices']; $startingChoicesEditor = $startingChoices; } } /* WAYFARER_BUILD_GAME_ENDINGS_V45 */ $postedGameEndings = isset($_POST['endings']) && is_array($_POST['endings']) ? array_values( $_POST['endings'] ) : []; $gameEndingsEditor = $postedGameEndings; if ($error === '') { $endingResult = wayfarerBuildGameEndingsFromPost( $pdo, (int)($game['id'] ?? 0), $postedGameEndings, $traits ); if ( $endingResult['error'] !== '' ) { $error = $endingResult['error']; } else { $gameEndings = $endingResult['endings']; $gameEndingsEditor = $gameEndings; } } /* WAYFARER_BUILD_GAME_TICK_EFFECTS_V33 */ $postedGameTickEffects = isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ? array_values( $_POST['tick_effects'] ) : []; $gameplayTickEffects = $postedGameTickEffects; if ($error === '') { $gameTickEffectResult = gameplayBuildTickEffectsFromPost( $gameplayRuntimeTargets, $postedGameTickEffects, $gameplayRuntimeTargets ); if ( $gameTickEffectResult['error'] !== '' ) { $error = $gameTickEffectResult['error']; } else { $gameTickEffects = $gameTickEffectResult['effects']; $gameplayTickEffects = $gameTickEffects; } } // ------------------------------------------------------------ // VALIDATE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['name'] === '') { $error = 'Game name is required.'; } elseif ($game['slug'] === '') { $error = 'Slug is required.'; } } // ------------------------------------------------------------ // BUILD CONFIG JSON // ------------------------------------------------------------ if ($error === '') { /* * Preserve any other configuration already stored for the game. * Traits own only the "traits" section of config. */ if (empty($config) && !empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } $startingRegionId = !empty($_POST['starting_region_id']) ? (int)$_POST['starting_region_id'] : null; if ($startingRegionId !== null) { $config['starting_region_id'] = $startingRegionId; } else { unset($config['starting_region_id']); } $config['traits'] = array_values( $traits ); /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ $config['player_traits'] = array_values( $playerTraits ); /* WAYFARER_SAVE_GAME_TICK_EFFECTS_V33 */ if ($gameTickEffects) { $config['tick_effects'] = array_values( $gameTickEffects ); } else { unset( $config['tick_effects'] ); } /* WAYFARER_SAVE_STARTING_CHOICES_V36 */ /* * WAYFARER_STARTING_CHOICES_CANONICAL_EMPTY_V36_1 * * If the Setup editor participated in this submission, its current * collection is the canonical collection. In particular, deleting the * final card must persist an explicit empty array rather than relying * on the absence of setup_choices in POST. */ if ($startingChoicesSubmitted) { if ( !isset($config['setup']) || !is_array($config['setup']) ) { $config['setup'] = []; } $config['setup']['choices'] = array_values( $startingChoices ); } /* WAYFARER_SAVE_GAME_ENDINGS_V45 */ $config['endings'] = array_values( $gameEndings ); $game['config'] = json_encode( $config, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ($game['config'] === false) { $error = 'Unable to encode game configuration.'; } } // ------------------------------------------------------------ // WRITE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['id']) { $stmt = $pdo->prepare(" UPDATE wf_games SET name = ?, slug = ?, description = ?, instructions = ?, player_slots = ?, config = ?, status = ? WHERE id = ? "); $stmt->execute([ $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'], $game['id'] ]); } else { $uuid = bin2hex(random_bytes(16)); $stmt = $pdo->prepare(" INSERT INTO wf_games ( uuid, name, slug, description, instructions, player_slots, config, status ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $uuid, $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'] ]); $game['id'] = (int)$pdo->lastInsertId(); } /* * WAYFARER_STAY_ON_GAME_SETTINGS_V38 * * Successful saves remain on the same Game Settings record and reopen * the tab the author was using. */ $savedGameTab = (string)( $_POST['active_tab'] ?? 'basics' ); if ( !in_array( $savedGameTab, [ 'basics', 'setup', 'traits', 'gameplay', 'endings', ], true ) ) { $savedGameTab = 'basics'; } header( 'Location: edit.php?id=' . (int)$game['id'] . '&tab=' . rawurlencode( $savedGameTab ) ); exit; } } // ================================================================ // LOAD REGIONS FOR STARTING REGION // ================================================================ $regions = []; if (!empty($game['id'])) { $regionStmt = $pdo->prepare(" SELECT id, name FROM wf_regions WHERE game_id = ? ORDER BY name "); $regionStmt->execute([ $game['id'] ]); $regions = $regionStmt->fetchAll(); } /* WAYFARER_GAME_SETTINGS_UNIVERSAL_TARGETS_RENDER_V56 */ $gameplayRuntimeTargets = gameplayRuntimeStateTargets( $pdo, (int)($game['id'] ?? 0), $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ) ); /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_RENDER_V54 */ $startingChoiceItemTargets = wayfarerStartingChoiceItemQuantityTargets( $pdo, (int)($game['id'] ?? 0) ); ?> <?= $game['id'] ? 'Edit Game' : 'New Game' ?> - Wayfarer


Example: stormgate

================================================================================ FILE: update-backups/v58-20260925-205136/assets/css/builder.css ================================================================================ /* |-------------------------------------------------------------------------- | Wayfarer Builder |-------------------------------------------------------------------------- | Master/detail authored-object workspace. | This stylesheet is intentionally presentation-only. */ :root { --wf-border: #d8dbe2; --wf-border-strong: #b8bdc7; --wf-surface: #ffffff; --wf-surface-soft: #f6f7f9; --wf-text: #20242a; --wf-muted: #667085; --wf-accent: #2f5ea8; --wf-accent-soft: #edf3fc; --wf-danger: #a61b2b; --wf-danger-soft: #fff4f5; --wf-radius: 8px; --wf-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); } .wayfarer-builder-page { margin: 0; padding: 24px; background: #f3f4f6; color: var(--wf-text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.45; } .wayfarer-builder-page > h1, .wayfarer-builder-page > p, .wayfarer-builder-page > nav, .wayfarer-builder-page > .builder-workspace, .wayfarer-builder-page > .builder-back-link { max-width: 1400px; margin-left: auto; margin-right: auto; } .wayfarer-builder-page > h1 { margin-bottom: 2px; } .wayfarer-builder-page > h1 + p { margin-top: 0; color: var(--wf-muted); } .wayfarer-builder-page > nav { margin-top: 18px; margin-bottom: 18px; padding: 10px 14px; background: var(--wf-surface); border: 1px solid var(--wf-border); border-radius: var(--wf-radius); box-shadow: var(--wf-shadow); } .wayfarer-builder-page > nav a, .wayfarer-builder-page > nav strong { display: inline-block; margin-right: 18px; } .wayfarer-builder-page > hr { display: none; } /* ------------------------------------------------------------------ MASTER / DETAIL ------------------------------------------------------------------ */ .builder-workspace { display: grid; grid-template-columns: minmax(260px, 340px) minmax(0, 1fr); gap: 18px; align-items: start; } .builder-collection-pane, .builder-editor-pane { background: var(--wf-surface); border: 1px solid var(--wf-border); border-radius: var(--wf-radius); box-shadow: var(--wf-shadow); } .builder-collection-pane { position: sticky; top: 18px; max-height: calc(100vh - 36px); overflow: hidden; } .builder-collection-toolbar { padding: 14px; border-bottom: 1px solid var(--wf-border); background: var(--wf-surface-soft); } .builder-new-object { display: inline-block; padding: 8px 11px; border: 1px solid var(--wf-border-strong); border-radius: 6px; background: var(--wf-surface); text-decoration: none; font-weight: 600; } .builder-collection-scroll { overflow: auto; max-height: calc(100vh - 92px); padding: 14px; } .builder-collection-scroll > h2 { margin-top: 0; margin-bottom: 12px; font-size: 1.15rem; } .builder-collection-scroll ul { margin: 0; padding: 0; list-style: none; } .builder-collection-scroll li + li { margin-top: 4px; } .builder-collection-scroll li a { display: block; padding: 8px 9px; border-radius: 5px; text-decoration: none; } .builder-collection-scroll li a:hover { background: var(--wf-accent-soft); } .builder-collection-scroll table { width: 100%; border-collapse: collapse; font-size: 0.88rem; } .builder-collection-scroll th, .builder-collection-scroll td { padding: 7px 6px; border: 1px solid var(--wf-border); text-align: left; vertical-align: top; } .builder-collection-scroll th button { border: 0; padding: 0; background: transparent; font: inherit; font-weight: 600; cursor: pointer; } .builder-editor-pane { min-width: 0; padding: 20px 24px 24px; } .builder-editor-pane > h2 { margin-top: 0; margin-bottom: 14px; } /* WAYFARER_OBJECT_CONTEXT_HEADING_V34 */ .builder-object-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 8px; } .builder-object-context { color: var(--wf-muted); font-weight: 600; } .builder-object-context[hidden] { display: none; } .builder-object-context-separator { margin-right: 8px; color: var(--wf-border-strong); } /* ------------------------------------------------------------------ TABBED OBJECT EDITOR ------------------------------------------------------------------ */ .builder-object-form { margin: 0; } .builder-editor-tabs { display: flex; gap: 4px; margin: 0 0 20px; padding: 0; border-bottom: 1px solid var(--wf-border); } .builder-editor-tab { position: relative; bottom: -1px; padding: 9px 14px; border: 1px solid transparent; border-bottom: 1px solid var(--wf-border); background: transparent; color: var(--wf-muted); font: inherit; font-weight: 600; cursor: pointer; } .builder-editor-tab:hover { color: var(--wf-text); background: var(--wf-surface-soft); } .builder-editor-tab.is-active { color: var(--wf-accent); background: var(--wf-surface); border-color: var(--wf-border) var(--wf-border) var(--wf-surface); border-radius: 6px 6px 0 0; } .builder-tab-panel[hidden] { display: none !important; } .builder-tab-panel > p:first-child { margin-top: 0; } /* ------------------------------------------------------------------ EXISTING FIELD CONTROLS ------------------------------------------------------------------ */ .builder-editor-pane label { font-weight: 600; } .builder-editor-pane small { color: var(--wf-muted); font-weight: 400; } .builder-editor-pane input[type="text"], .builder-editor-pane input[type="number"], .builder-editor-pane input[type="file"], .builder-editor-pane select, .builder-editor-pane textarea { box-sizing: border-box; max-width: 100%; margin-top: 4px; padding: 7px 8px; border: 1px solid var(--wf-border-strong); border-radius: 5px; background: var(--wf-surface); color: var(--wf-text); font: inherit; } .builder-editor-pane input[type="text"], .builder-editor-pane textarea { width: min(100%, 720px); } .builder-editor-pane textarea { resize: vertical; } .builder-editor-pane fieldset { margin: 0 0 16px; padding: 14px 16px; border: 1px solid var(--wf-border); border-radius: 7px; } .builder-editor-pane legend { padding: 0 6px; } .builder-editor-pane table { max-width: 100%; border-collapse: collapse; } .builder-editor-pane th, .builder-editor-pane td { padding: 7px; } /* System settings stay visually inside Basics. */ .builder-editor-pane [data-system-trait-settings] { margin: 16px 0; padding: 14px 16px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface-soft); } .builder-editor-pane [data-system-trait-settings] h3 { margin-top: 0; } /* ------------------------------------------------------------------ SAVE / DELETE ------------------------------------------------------------------ */ .builder-save-bar { display: flex; justify-content: flex-end; margin: 22px -24px -24px; padding: 14px 24px; border-top: 1px solid var(--wf-border); background: var(--wf-surface-soft); border-radius: 0 0 var(--wf-radius) var(--wf-radius); } .builder-save-bar button { padding: 9px 16px; border: 1px solid #214b86; border-radius: 6px; background: var(--wf-accent); color: #fff; font: inherit; font-weight: 600; cursor: pointer; } .builder-editor-pane [data-builder-delete-form] { margin-top: 28px; padding: 16px; border: 1px solid #e8b8be; border-radius: 7px; background: var(--wf-danger-soft); } .builder-editor-pane [data-builder-delete-form] h3 { margin-top: 0; color: var(--wf-danger); } .builder-editor-pane [data-builder-delete-form] button { padding: 8px 12px; border: 1px solid var(--wf-danger); border-radius: 5px; background: #fff; color: var(--wf-danger) !important; font: inherit; font-weight: 600; cursor: pointer; } .builder-back-link { margin-top: 18px; } /* ------------------------------------------------------------------ RESPONSIVE ------------------------------------------------------------------ */ @media (max-width: 900px) { .wayfarer-builder-page { padding: 14px; } .builder-workspace { grid-template-columns: 1fr; } .builder-collection-pane { position: static; max-height: none; } .builder-collection-scroll { max-height: 300px; } .builder-editor-pane { padding: 18px; } .builder-save-bar { margin-left: -18px; margin-right: -18px; margin-bottom: -18px; padding-left: 18px; padding-right: 18px; } } @media (max-width: 560px) { .builder-editor-tabs { overflow-x: auto; } .builder-editor-tab { white-space: nowrap; } } /* |-------------------------------------------------------------------------- | REGION TICK EFFECTS v18 |-------------------------------------------------------------------------- */ .builder-tick-effect-row { display: grid; grid-template-columns: minmax(220px, 2fr) minmax(130px, 1fr) minmax(110px, 1fr) auto; gap: 12px; align-items: end; margin: 0 0 12px; padding: 14px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface-soft); } .builder-tick-effect-row > p { margin: 0; } .builder-tick-effect-row select, .builder-tick-effect-row input { width: 100%; box-sizing: border-box; } .builder-tick-effect-remove { align-self: end; } @media (max-width: 760px) { .builder-tick-effect-row { grid-template-columns: 1fr; } } /* |-------------------------------------------------------------------------- | TICK EFFECT RESULT BRANCHES v28 |-------------------------------------------------------------------------- | WAYFARER_TICK_EFFECT_BRANCHES_V28 */ .builder-tick-effect-row { display: block; margin: 0 0 16px; padding: 16px; } .builder-tick-effect-header { display: grid; grid-template-columns: minmax(260px, 1fr) auto; gap: 16px; align-items: end; margin-bottom: 16px; } .builder-tick-effect-target select { display: block; width: 100%; margin-top: 6px; } .builder-tick-result-list { display: grid; gap: 12px; } .builder-tick-result-branch { padding: 14px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface); } .builder-tick-result-branch.is-otherwise { background: var(--wf-surface-soft); } .builder-tick-result-heading { display: flex; justify-content: space-between; gap: 12px; align-items: center; margin-bottom: 12px; } .builder-tick-result-order { display: flex; gap: 6px; align-items: center; } .builder-tick-result-condition { margin-bottom: 10px; } .builder-tick-result-then { margin: 10px 0 8px; } .builder-tick-result-fields { display: grid; grid-template-columns: minmax(160px, 220px) minmax(120px, 180px); gap: 12px; align-items: end; } .builder-tick-result-fields select, .builder-tick-result-fields input { display: block; width: 100%; margin-top: 6px; } .builder-tick-result-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; } .builder-condition-group { padding: 10px; border: 1px solid var(--wf-border); border-radius: 6px; background: var(--wf-surface-soft); } .builder-condition-group-header, .builder-condition-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .builder-condition-children { display: grid; gap: 8px; margin: 10px 0; } .builder-condition-row { display: grid; grid-template-columns: minmax(180px, 1.4fr) minmax(180px, 1fr) minmax(120px, 1fr) auto; gap: 8px; align-items: end; } .builder-condition-row select, .builder-condition-row input, .builder-condition-group-header select { width: 100%; box-sizing: border-box; } @media (max-width: 760px) { .builder-tick-effect-header, .builder-tick-result-fields, .builder-condition-row { grid-template-columns: 1fr; } .builder-tick-result-heading { align-items: flex-start; flex-direction: column; } } /* ------------------------------------------------------------------ STARTING CHOICES WAYFARER_STARTING_CHOICES_V36 ------------------------------------------------------------------ */ .builder-starting-choices-editor { margin-top: 24px; } .builder-starting-choices-heading, .builder-starting-choice-header, .builder-starting-option-header, .builder-starting-effects-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } .builder-starting-choices-heading h3 { margin: 0 0 4px; } .builder-starting-choices-heading p { margin: 0; } .builder-starting-choice { margin-top: 16px; padding: 16px; border: 1px solid var(--wf-border); border-radius: var(--wf-radius); background: var(--wf-surface-soft); } .builder-starting-choice-fields { display: grid; grid-template-columns: minmax(0, 1fr) 140px; gap: 12px; margin-top: 14px; } .builder-starting-choice-fields label, .builder-starting-option-fields label, .builder-starting-effect label { display: grid; gap: 5px; font-weight: 600; } .builder-starting-choice-fields input, .builder-starting-choice-fields select, .builder-starting-option-fields input, .builder-starting-option-fields textarea, .builder-starting-effect input, .builder-starting-effect select { width: 100%; font: inherit; } .builder-starting-options { display: grid; gap: 12px; margin: 16px 0 12px; } .builder-starting-option { padding: 14px; border: 1px solid var(--wf-border); border-radius: 7px; background: var(--wf-surface); } .builder-starting-option-fields { display: grid; grid-template-columns: minmax(180px, 0.7fr) minmax(260px, 1.3fr); gap: 12px; margin-top: 12px; } .builder-starting-effects { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--wf-border); } .builder-starting-effects-heading { margin-bottom: 8px; } .builder-starting-effects-heading small { color: var(--wf-muted); } .builder-starting-effect { display: grid; grid-template-columns: minmax(180px, 1.4fr) minmax(120px, 0.7fr) minmax(100px, 0.6fr) auto; gap: 10px; align-items: end; margin-bottom: 8px; } @media (max-width: 800px) { .builder-starting-choice-fields, .builder-starting-option-fields, .builder-starting-effect { grid-template-columns: 1fr; } } /* ------------------------------------------------------------------ ONE-TIME EFFECTS WAYFARER_ONE_TIME_EFFECTS_EDITOR_V42 ------------------------------------------------------------------ */ .builder-effects-panel > hr { margin: 28px 0; border: 0; border-top: 1px solid var(--wf-border, #d8dbe2); } .builder-effects-list { display: grid; gap: 10px; margin: 14px 0; } .builder-effect-row { display: grid; grid-template-columns: minmax(220px, 1.5fr) minmax(130px, 0.7fr) minmax(110px, 0.6fr) auto; gap: 10px; align-items: end; padding: 12px; border: 1px solid var(--wf-border, #d8dbe2); border-radius: 8px; background: #ffffff; } .builder-effect-row label { display: grid; gap: 5px; } .builder-effect-row select, .builder-effect-row input { width: 100%; } .builder-effect-remove { align-self: end; } @media (max-width: 850px) { .builder-effect-row { grid-template-columns: 1fr; } .builder-effect-remove { justify-self: start; } } /* ------------------------------------------------------------------ GENERALIZED EFFECTS WAYFARER_GENERALIZED_EFFECT_EDITOR_V43 ------------------------------------------------------------------ */ /* * v42 displayed an Effect as one horizontal operation row. v43 Effects own * ordered result branches, so use the same card language as Tick Effects. */ .builder-effect-row { display: block; grid-template-columns: none; padding: 14px; } .builder-effect-row .builder-tick-effect-header { margin-bottom: 12px; } .builder-effect-value-fields { display: grid; grid-template-columns: minmax(120px, 0.75fr) minmax(120px, 0.75fr) minmax(120px, 0.75fr); gap: 10px; align-items: end; } .builder-effect-random-range { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .builder-effect-random-range > label { display: grid; gap: 5px; } [data-effect-fixed-wrap][hidden], [data-effect-random-wrap][hidden], [data-effect-value-mode-wrap][hidden] { display: none !important; } @media (max-width: 850px) { .builder-effect-value-fields { grid-template-columns: 1fr; } .builder-effect-random-range { grid-template-columns: 1fr; } } /* ------------------------------------------------------------------ STORYLET BRANCHES WAYFARER_STORYLET_BUILDER_V56 ------------------------------------------------------------------ */ .builder-storylet-branches { max-width: 100%; overflow-x: auto; } .builder-storylet-branches table { width: 100%; border-collapse: collapse; } .builder-storylet-branches th, .builder-storylet-branches td { padding: 9px 10px; border: 1px solid var(--wf-border); text-align: left; vertical-align: top; } .builder-storylet-branches th { background: var(--wf-surface-soft); } ================================================================================ FILE: update-backups/v58-20260925-205136/assets/js/builder.js ================================================================================ const wayfarerConditionData = window.wayfarerConditionData || {}; function updateConditionalConfigFields() { document .querySelectorAll('[data-config-field]') .forEach(function (container) { const relationshipName = container.dataset.conditionRelationship; const requiredValue = container.dataset.conditionEquals; if (!relationshipName) { container.style.display = ''; return; } const relationshipSelect = document.querySelector( '[data-relationship="' + relationshipName + '"]' ); if (!relationshipSelect) { container.style.display = 'none'; return; } const selectedId = relationshipSelect.value; const actualValue = wayfarerConditionData[relationshipName]?.[selectedId]; container.style.display = actualValue === requiredValue ? '' : 'none'; }); } document .querySelectorAll('[data-relationship]') .forEach(function (select) { select.addEventListener( 'change', updateConditionalConfigFields ); }); // ================================================================ // SYSTEM TRAIT GROUPS // ================================================================ function updateSystemTraitGroups(container) { const selectorField = container.dataset.selectorField; if (!selectorField) { return; } const selector = document.querySelector( '[name="' + selectorField + '"]' ); if (!selector) { return; } const selectedGroup = selector.value; container .querySelectorAll('[data-system-trait-group]') .forEach(function (group) { const active = group.dataset.systemTraitGroup === selectedGroup; group.style.display = active ? '' : 'none'; group .querySelectorAll('input, select, textarea') .forEach(function (input) { input.disabled = !active; }); }); } document .querySelectorAll('[data-system-trait-settings]') .forEach(function (container) { const selectorField = container.dataset.selectorField; const selector = selectorField ? document.querySelector( '[name="' + selectorField + '"]' ) : null; if (selector) { selector.addEventListener( 'change', function () { updateSystemTraitGroups(container); } ); } updateSystemTraitGroups(container); }); // ================================================================ // COLLECTION SORTING // ================================================================ document .querySelectorAll('[data-sort-column]') .forEach(function (button) { button.addEventListener('click', function () { const table = button.closest('[data-collection-table]'); if (!table) { return; } const tbody = table.querySelector('tbody'); const columnName = button.dataset.sortColumn; const sortType = button.dataset.sortType || 'text'; const currentDirection = button.dataset.sortDirection || 'none'; const nextDirection = currentDirection === 'asc' ? 'desc' : 'asc'; table .querySelectorAll('[data-sort-column]') .forEach(function (otherButton) { otherButton.dataset.sortDirection = 'none'; }); button.dataset.sortDirection = nextDirection; const rows = Array.from(tbody.querySelectorAll('tr')); rows.sort(function (a, b) { const aCell = a.querySelector( '[data-column="' + columnName + '"]' ); const bCell = b.querySelector( '[data-column="' + columnName + '"]' ); const aValue = aCell?.dataset.sortValue ?? ''; const bValue = bCell?.dataset.sortValue ?? ''; let comparison = 0; if (sortType === 'number') { const aNumber = aValue === '' ? Number.POSITIVE_INFINITY : Number(aValue); const bNumber = bValue === '' ? Number.POSITIVE_INFINITY : Number(bValue); comparison = aNumber - bNumber; } else { comparison = aValue.localeCompare( bValue, undefined, { numeric: true, sensitivity: 'base' } ); } return nextDirection === 'asc' ? comparison : -comparison; }); rows.forEach(function (row) { tbody.appendChild(row); }); }); }); updateConditionalConfigFields(); // ================================================================ // MEDIA LIBRARY CONTROLS // ================================================================ function updateBuilderMediaSlot(slotContainer) { const checkedAction = slotContainer.querySelector( 'input[type="radio"][name^="media_action"]:checked' ); const action = checkedAction ? checkedAction.value : 'none'; const existingSelect = slotContainer.querySelector( '[data-media-existing]' ); if (existingSelect) { existingSelect.disabled = action !== 'existing'; } slotContainer .querySelectorAll( '[data-media-upload-fields] input' ) .forEach(function (input) { input.disabled = action !== 'upload'; }); } document .querySelectorAll('[data-media-slot]') .forEach(function (slotContainer) { slotContainer .querySelectorAll( 'input[type="radio"][name^="media_action"]' ) .forEach(function (radio) { radio.addEventListener( 'change', function () { updateBuilderMediaSlot( slotContainer ); } ); }); updateBuilderMediaSlot( slotContainer ); }); // ================================================================ // AUTHORED OBJECT CONTEXT HEADING // WAYFARER_OBJECT_CONTEXT_HEADING_V34 // ================================================================ function initializeBuilderObjectContextHeading() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } const heading = editor.querySelector( '[data-builder-object-heading]' ); if (!heading) { return; } const fieldName = heading.dataset.builderObjectLabelField || 'name'; const form = editor.querySelector( 'form' ); if (!form) { return; } const field = Array .from( form.elements ) .find(function (element) { return ( element && element.name === fieldName ); }); if (!field) { return; } const context = heading.querySelector( '[data-builder-object-context]' ); const label = heading.querySelector( '[data-builder-object-context-label]' ); if (!context || !label) { return; } function refreshContextHeading() { const value = String( field.value || '' ).trim(); label.textContent = value; context.hidden = value === ''; } field.addEventListener( 'input', refreshContextHeading ); field.addEventListener( 'change', refreshContextHeading ); refreshContextHeading(); } initializeBuilderObjectContextHeading(); // ================================================================ // AUTHORED OBJECT EDITOR TABS // ================================================================ function initializeBuilderEditorTabs() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } /* * The editor component has one primary save form. The separate delete * form, when present, lives later in the editor pane and is deliberately * left outside the tab system. */ const form = Array .from(editor.children) .find(function (child) { return child.tagName === 'FORM'; }); if ( !form || form.dataset.builderTabbed === '1' ) { return; } form.dataset.builderTabbed = '1'; form.classList.add( 'builder-object-form' ); /* * WAYFARER_ACTIVE_TAB_V20 * * Persist the selected editor tab through POST/redirect/reload. */ const activeTabInput = document.createElement( 'input' ); activeTabInput.type = 'hidden'; activeTabInput.name = 'active_tab'; const requestedTab = new URLSearchParams( window.location.search ).get( 'tab' ) || 'basics'; activeTabInput.value = requestedTab; form.appendChild( activeTabInput ); const originalChildren = Array.from( form.children ); const panels = { basics: document.createElement('div'), media: document.createElement('div'), traits: document.createElement('div'), /* WAYFARER_CONDITIONS_PANEL_V45_1 */ conditions: document.createElement('div'), tick_effects: document.createElement('div'), /* WAYFARER_STORYLET_BUILDER_V56 */ /* WAYFARER_STORYLET_BRANCH_PANEL_KEY_V57 */ branches: document.createElement('div') }; panels.basics.className = 'builder-tab-panel'; panels.media.className = 'builder-tab-panel'; panels.traits.className = 'builder-tab-panel'; panels.conditions.className = 'builder-tab-panel'; panels.branches.className = 'builder-tab-panel'; panels.basics.dataset.builderPanel = 'basics'; panels.media.dataset.builderPanel = 'media'; panels.traits.dataset.builderPanel = 'traits'; panels.conditions.dataset.builderPanel = 'conditions'; panels.branches.dataset.builderPanel = 'branches'; panels.tick_effects.className = 'builder-tab-panel'; panels.tick_effects.dataset.builderPanel = 'tick_effects'; let section = 'basics'; let saveContainer = null; let hasMedia = false; let hasTraits = false; let hasConditions = false; let hasTickEffects = false; let hasBranches = false; originalChildren.forEach( function (child) { /* * Keep hidden record identifiers directly in the form. */ if ( child.tagName === 'INPUT' && child.type === 'hidden' ) { return; } /* * Save becomes a stable footer beneath the tab panels. */ const submitButton = child.querySelector ? child.querySelector( 'button[type="submit"], input[type="submit"]' ) : null; if (submitButton) { saveContainer = child; return; } /* * Ignore the separator immediately before a tab heading. * Other HR elements are harmless but unnecessary inside the * cleaned-up editor. */ if (child.tagName === 'HR') { child.remove(); return; } if (child.tagName === 'H3') { const heading = child.textContent .trim() .toLowerCase(); if (heading === 'media') { section = 'media'; hasMedia = true; child.remove(); return; } if (heading === 'traits') { section = 'traits'; hasTraits = true; child.remove(); return; } } /* * WAYFARER_CONDITIONS_PANEL_V45_1 * * Conditions answer whether an authored object is eligible; * Effects answer what changes when it triggers. */ if ( child.hasAttribute && child.hasAttribute( 'data-builder-conditions-panel' ) ) { section = 'conditions'; hasConditions = true; } /* * WAYFARER_ONE_TIME_EFFECTS_EDITOR_V42 * * Location Effects and Tick Effects share one Effects panel. */ if ( child.hasAttribute && ( child.hasAttribute( 'data-builder-effects-panel' ) || child.hasAttribute( 'data-builder-tick-effects' ) ) ) { section = 'tick_effects'; hasTickEffects = true; } /* WAYFARER_STORYLET_BRANCHES_PANEL_V56 */ if ( child.hasAttribute && child.hasAttribute( 'data-builder-branches-panel' ) ) { section = 'branches'; hasBranches = true; } /* * Config fields belong to Basics even though the old v9 Media * markup happens to precede them in the source template. */ if ( child.hasAttribute && child.hasAttribute( 'data-config-field' ) ) { section = 'basics'; } /* * A Media fieldset is definitive even if future markup changes * around the Media heading. */ if ( child.hasAttribute && child.hasAttribute( 'data-media-slot' ) ) { section = 'media'; hasMedia = true; } panels[section] .appendChild( child ); } ); /* * If Traits exist, anything moved after the Traits heading belongs there. * System traits do not use that heading and therefore stay in Basics. */ if ( panels.traits.children.length > 0 ) { hasTraits = true; } if ( panels.media.children.length > 0 ) { hasMedia = true; } if ( panels.conditions.children.length > 0 ) { hasConditions = true; } if ( panels.tick_effects.children.length > 0 ) { hasTickEffects = true; } if ( panels.branches.children.length > 0 ) { hasBranches = true; } const tabBar = document.createElement( 'div' ); tabBar.className = 'builder-editor-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const availablePanels = [ { key: 'basics', label: 'Basics', show: true }, { key: 'media', label: 'Media', show: hasMedia }, { key: 'traits', label: 'Traits', show: hasTraits }, { key: 'conditions', label: 'Conditions', show: hasConditions }, { key: 'tick_effects', label: 'Effects', show: hasTickEffects }, { key: 'branches', label: 'Branches', show: hasBranches } ].filter( function (item) { return item.show; } ); function activatePanel(panelKey) { activeTabInput.value = panelKey; tabBar .querySelectorAll( '[data-builder-tab]' ) .forEach( function (button) { const active = button.dataset.builderTab === panelKey; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); Object .entries(panels) .forEach( function (entry) { const key = entry[0]; const panel = entry[1]; panel.hidden = key !== panelKey; } ); } availablePanels.forEach( function (item) { const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'builder-editor-tab'; button.dataset.builderTab = item.key; button.setAttribute( 'role', 'tab' ); button.textContent = item.label; button.addEventListener( 'click', function () { activatePanel( item.key ); } ); tabBar.appendChild( button ); } ); /* * Insert the new structure after hidden inputs so all form fields remain * part of the same form and submit exactly as before. */ form.appendChild( tabBar ); availablePanels.forEach( function (item) { form.appendChild( panels[item.key] ); } ); if (saveContainer) { saveContainer.classList.add( 'builder-save-bar' ); form.appendChild( saveContainer ); } const initialPanelKey = availablePanels.some( function (item) { return item.key === requestedTab; } ) ? requestedTab : 'basics'; activatePanel( initialPanelKey ); } initializeBuilderEditorTabs(); // ================================================================ // SHARED GAMEPLAY TICK EFFECTS CONTROLS v25 // WAYFARER_GAMEPLAY_MODULAR_V25 // ================================================================ function initializeTickEffects() { const editor = document.querySelector( '[data-builder-editor]' ); if (!editor) { return; } const wrapper = editor.querySelector( '[data-builder-tick-effects]' ); if (!wrapper) { return; } if ( wrapper.dataset.tickEffectsReady === '1' ) { return; } wrapper.dataset.tickEffectsReady = '1'; // ------------------------------------------------------------ // ROW ADD / REMOVE // ------------------------------------------------------------ const list = wrapper.querySelector( '[data-tick-effects-list]' ); const addButton = wrapper.querySelector( '[data-add-tick-effect]' ); const template = wrapper.querySelector( '[data-tick-effect-template]' ); let nextIndex = 0; wrapper .querySelectorAll( '[data-tick-effect-row]' ) .forEach( function (row) { const index = Number( row.dataset.tickIndex ); if ( Number.isFinite(index) && index >= nextIndex ) { nextIndex = index + 1; } } ); function updateLinearTargetAvailability() { const navigationSelect = editor.querySelector( 'select[name="navigation_type"]' ); const navigationType = navigationSelect ? navigationSelect.value : wrapper.dataset.navigationType; const linear = navigationType === 'linear'; wrapper .querySelectorAll( '[data-linear-navigation-target]' ) .forEach( function (option) { option.disabled = !linear; } ); if (!linear) { wrapper .querySelectorAll( '[data-tick-effect-target]' ) .forEach( function (select) { if ( select.value === 'navigation:position' ) { select.value = ''; } } ); } } if ( addButton && list && template ) { addButton.addEventListener( 'click', function () { const html = template.innerHTML.replace( /__INDEX__/g, String( nextIndex++ ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const row = temporary.firstElementChild; if (!row) { return; } list.appendChild( row ); updateLinearTargetAvailability(); } ); } wrapper.addEventListener( 'click', function (event) { const removeButton = event.target.closest( '[data-remove-tick-effect]' ); if (!removeButton) { return; } const row = removeButton.closest( '[data-tick-effect-row]' ); if (row) { row.remove(); } } ); const navigationSelect = editor.querySelector( 'select[name="navigation_type"]' ); if (navigationSelect) { navigationSelect.addEventListener( 'change', updateLinearTargetAvailability ); } updateLinearTargetAvailability(); /* * WAYFARER_EMBEDDED_TICK_EFFECTS_V33 * * Game Settings already owns a Gameplay tab. In embedded mode we keep * this shared editor exactly where PHP rendered it while retaining the * normal Tick Effect, condition, and result-branch controls. */ if ( wrapper.hasAttribute( 'data-tick-effects-embedded' ) ) { return; } // ------------------------------------------------------------ // MOVE THE SECTION INTO ITS OWN BUILDER TAB // ------------------------------------------------------------ const form = wrapper.closest( 'form' ); if (!form) { return; } const tabBar = form.querySelector( '.builder-editor-tabs' ); if (!tabBar) { /* * Builder without the v10 tab layout: * leave the Tick Effects section inline. */ return; } if ( form.querySelector( '[data-builder-panel="tick_effects"]' ) ) { return; } const panel = document.createElement( 'div' ); panel.className = 'builder-tab-panel'; panel.dataset.builderPanel = 'tick_effects'; panel.hidden = true; panel.appendChild( wrapper ); const saveBar = form.querySelector( '.builder-save-bar' ); if (saveBar) { form.insertBefore( panel, saveBar ); } else { form.appendChild( panel ); } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'builder-editor-tab'; button.dataset.builderTab = 'tick_effects'; button.setAttribute( 'role', 'tab' ); button.setAttribute( 'aria-selected', 'false' ); /* WAYFARER_GAMEPLAY_TAB_LABEL_V25_1 */ button.textContent = 'Effects'; tabBar.appendChild( button ); function activateTickEffectsTab() { tabBar .querySelectorAll( '[data-builder-tab]' ) .forEach( function (tabButton) { const active = tabButton === button; tabButton.classList.toggle( 'is-active', active ); tabButton.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); form .querySelectorAll( '[data-builder-panel]' ) .forEach( function (tabPanel) { tabPanel.hidden = tabPanel.dataset.builderPanel !== 'tick_effects'; } ); } button.addEventListener( 'click', activateTickEffectsTab ); /* * The original tab controller does not know about the panel added * afterward, so explicitly hide our panel whenever an original tab * is clicked. */ tabBar .querySelectorAll( '[data-builder-tab]:not([data-builder-tab="tick_effects"])' ) .forEach( function (originalButton) { originalButton.addEventListener( 'click', function () { panel.hidden = true; button.classList.remove( 'is-active' ); button.setAttribute( 'aria-selected', 'false' ); } ); } ); } // ================================================================ // SHARED ONE-TIME EFFECTS CONTROLS // WAYFARER_ONE_TIME_EFFECTS_EDITOR_V42 // WAYFARER_GENERALIZED_EFFECT_EDITOR_V43 // ================================================================ function wayfarerEffectConditionTargets( wrapper ) { const targetJson = wrapper.querySelector( '[data-effect-condition-targets-json]' ); if (!targetJson) { return {}; } try { return JSON.parse( targetJson.textContent || '{}' ); } catch (error) { return {}; } } function wayfarerEffectBuildConditionEditor( host, name, targets ) { const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( name, targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } function wayfarerEffectUpdateResultFields( branch ) { const operation = branch.querySelector( '[data-effect-branch-operation]' ); const modeWrap = branch.querySelector( '[data-effect-value-mode-wrap]' ); const mode = branch.querySelector( '[data-effect-value-mode]' ); const fixedWrap = branch.querySelector( '[data-effect-fixed-wrap]' ); const randomWrap = branch.querySelector( '[data-effect-random-wrap]' ); if (!operation) { return; } const noChange = operation.value === 'none'; if (modeWrap) { modeWrap.hidden = noChange; modeWrap .querySelectorAll( 'select, input' ) .forEach( function (field) { field.disabled = noChange; } ); } const valueMode = mode ? mode.value : 'fixed'; if (fixedWrap) { fixedWrap.hidden = noChange || valueMode !== 'fixed'; fixedWrap .querySelectorAll( 'input' ) .forEach( function (field) { field.disabled = noChange || valueMode !== 'fixed'; } ); } if (randomWrap) { randomWrap.hidden = noChange || valueMode !== 'random'; randomWrap .querySelectorAll( 'input' ) .forEach( function (field) { field.disabled = noChange || valueMode !== 'random'; } ); } } function wayfarerEffectReindexBranches( effectRow ) { const effectIndex = effectRow.dataset.effectIndex; const list = effectRow.querySelector( '[data-effect-branch-list]' ); if ( effectIndex === undefined || !list ) { return; } const branches = Array .from( list.children ) .filter( function (child) { return ( child.matches && child.matches( '[data-effect-branch]' ) ); } ); branches.forEach( function ( branch, branchIndex ) { branch.dataset.branchIndex = String( branchIndex ); branch .querySelectorAll( '[name]' ) .forEach( function (field) { field.name = field.name.replace( /effects\[\d+\]\[branches\]\[\d+\]/, 'effects[' + effectIndex + '][branches][' + branchIndex + ']' ); } ); } ); } function wayfarerEffectRefreshBranchControls( effectRow ) { const list = effectRow.querySelector( '[data-effect-branch-list]' ); if (!list) { return; } const branches = Array .from( list.children ) .filter( function (child) { return ( child.matches && child.matches( '[data-effect-branch]' ) ); } ); const conditionals = branches.filter( function (branch) { return ( branch.dataset.branchKind === 'conditional' ); } ); conditionals.forEach( function ( branch, index ) { const up = branch.querySelector( '[data-move-effect-branch-up]' ); const down = branch.querySelector( '[data-move-effect-branch-down]' ); if (up) { up.disabled = index === 0; } if (down) { down.disabled = index === conditionals.length - 1; } } ); const hasOtherwise = branches.some( function (branch) { return ( branch.dataset.branchKind === 'otherwise' ); } ); const addOtherwise = effectRow.querySelector( '[data-add-effect-otherwise]' ); if (addOtherwise) { addOtherwise.disabled = hasOtherwise; } branches.forEach( wayfarerEffectUpdateResultFields ); wayfarerEffectReindexBranches( effectRow ); } function initializeEffects() { document .querySelectorAll( '[data-builder-effects]' ) .forEach( function (wrapper) { if ( wrapper.dataset.effectsReady === '1' ) { return; } wrapper.dataset.effectsReady = '1'; const list = wrapper.querySelector( '[data-effects-list]' ); const addButton = wrapper.querySelector( '[data-add-effect]' ); const effectTemplate = wrapper.querySelector( '[data-effect-template]' ); const conditionalTemplate = wrapper.querySelector( '[data-effect-conditional-template]' ); const otherwiseTemplate = wrapper.querySelector( '[data-effect-otherwise-template]' ); const conditionTargets = wayfarerEffectConditionTargets( wrapper ); let nextIndex = 0; wrapper .querySelectorAll( '[data-effect-row]' ) .forEach( function (row) { const index = Number( row.dataset.effectIndex ); if ( Number.isFinite( index ) && index >= nextIndex ) { nextIndex = index + 1; } wayfarerEffectRefreshBranchControls( row ); } ); if ( addButton && list && effectTemplate ) { addButton.addEventListener( 'click', function () { const effectIndex = nextIndex++; const html = effectTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', String( effectIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const row = temporary.firstElementChild; if (!row) { return; } list.appendChild( row ); wayfarerEffectRefreshBranchControls( row ); } ); } wrapper.addEventListener( 'change', function (event) { const operation = event.target.closest( '[data-effect-branch-operation]' ); const valueMode = event.target.closest( '[data-effect-value-mode]' ); if ( !operation && !valueMode ) { return; } const branch = event.target.closest( '[data-effect-branch]' ); if (branch) { wayfarerEffectUpdateResultFields( branch ); } } ); wrapper.addEventListener( 'click', function (event) { const effectRow = event.target.closest( '[data-effect-row]' ); const removeEffect = event.target.closest( '[data-remove-effect]' ); if (removeEffect) { if (!effectRow) { return; } if ( !window.confirm( 'Remove this Effect?' ) ) { return; } effectRow.remove(); return; } if (!effectRow) { return; } const addConditional = event.target.closest( '[data-add-effect-conditional]' ); if (addConditional) { if (!conditionalTemplate) { return; } const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if (!branchList) { return; } const effectIndex = effectRow.dataset.effectIndex; const branchIndex = branchList .querySelectorAll( ':scope > [data-effect-branch]' ) .length; const html = conditionalTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } const otherwise = Array .from( branchList.children ) .find( function (child) { return ( child.dataset && child.dataset.branchKind === 'otherwise' ); } ); if (otherwise) { branchList.insertBefore( branch, otherwise ); } else { branchList.appendChild( branch ); } wayfarerEffectReindexBranches( effectRow ); const newIndex = branch.dataset.branchIndex; const host = branch.querySelector( '[data-new-effect-condition-editor]' ); if (host) { wayfarerEffectBuildConditionEditor( host, 'effects[' + effectIndex + '][branches][' + newIndex + '][conditions]', conditionTargets ); } wayfarerEffectRefreshBranchControls( effectRow ); return; } const addOtherwise = event.target.closest( '[data-add-effect-otherwise]' ); if (addOtherwise) { if (!otherwiseTemplate) { return; } const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if ( !branchList || branchList.querySelector( '[data-branch-kind="otherwise"]' ) ) { return; } const effectIndex = effectRow.dataset.effectIndex; const branchIndex = branchList .querySelectorAll( ':scope > [data-effect-branch]' ) .length; const html = otherwiseTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } branchList.appendChild( branch ); wayfarerEffectRefreshBranchControls( effectRow ); return; } const removeBranch = event.target.closest( '[data-remove-effect-branch]' ); if (removeBranch) { const branch = removeBranch.closest( '[data-effect-branch]' ); if (branch) { branch.remove(); wayfarerEffectRefreshBranchControls( effectRow ); } return; } const moveUp = event.target.closest( '[data-move-effect-branch-up]' ); const moveDown = event.target.closest( '[data-move-effect-branch-down]' ); if ( !moveUp && !moveDown ) { return; } const branch = event.target.closest( '[data-effect-branch]' ); const branchList = effectRow.querySelector( '[data-effect-branch-list]' ); if ( !branch || !branchList || branch.dataset.branchKind !== 'conditional' ) { return; } const conditionals = Array .from( branchList.children ) .filter( function (child) { return ( child.dataset && child.dataset.branchKind === 'conditional' ); } ); const index = conditionals.indexOf( branch ); if ( moveUp && index > 0 ) { branchList.insertBefore( branch, conditionals[ index - 1 ] ); } if ( moveDown && index >= 0 && index < conditionals.length - 1 ) { const next = conditionals[ index + 1 ]; branchList.insertBefore( next, branch ); } wayfarerEffectRefreshBranchControls( effectRow ); } ); } ); } initializeEffects(); initializeTickEffects();initializeTickEffects(); // ================================================================ // REUSABLE CONDITION EDITOR v27 // WAYFARER_CONDITION_EDITOR_V27 // ================================================================ function wayfarerConditionEscape(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function wayfarerConditionTargetOptions( targets ) { const groups = {}; Object .entries( targets || {} ) .forEach( function (entry) { const key = entry[0]; const target = entry[1] || {}; const group = target.group || 'Targets'; if (!groups[group]) { groups[group] = []; } groups[group].push({ key: key, label: target.label || key }); } ); let html = ''; Object .entries(groups) .forEach( function (entry) { html += ''; entry[1] .forEach( function (target) { html += ''; } ); html += ''; } ); return html; } function wayfarerConditionNextChildIndex( group ) { let maxIndex = -1; const children = Array .from( group.children ) .find( function (child) { return child.hasAttribute( 'data-condition-children' ); } ); if (children) { Array .from( children.children ) .forEach( function (node) { let index = Number( node.dataset.conditionChildIndex ); if (!Number.isFinite(index)) { const namedInput = node.querySelector( '[name]' ); const match = namedInput ? namedInput.name.match( /\[children\]\[(\d+)\]/ ) : null; index = match ? Number(match[1]) : -1; } if ( Number.isFinite(index) && index > maxIndex ) { maxIndex = index; } } ); } return maxIndex + 1; } function wayfarerConditionCreateCondition( name, targets, childIndex ) { const row = document.createElement( 'div' ); row.className = 'builder-condition-row'; row.dataset.conditionNode = '1'; row.dataset.conditionType = 'condition'; row.dataset.conditionChildIndex = String(childIndex); row.innerHTML = '' + '' + '' + '' + ''; return row; } function wayfarerConditionCreateGroup( name, targets, childIndex, isRoot ) { const group = document.createElement( 'div' ); group.className = 'builder-condition-group' + ( isRoot ? ' builder-condition-group-root' : '' ); group.dataset.conditionNode = '1'; group.dataset.conditionType = 'group'; if ( childIndex !== null && childIndex !== undefined ) { group.dataset.conditionChildIndex = String(childIndex); } group.innerHTML = '' + '
' + '' + ' of the following' + ( isRoot ? '' : '' ) + '
' + '
' + '
' + ' ' + '' + '
'; return group; } function wayfarerConditionInitializeEditor( editor, targets ) { if ( !editor || editor.dataset.conditionEditorReady === '1' ) { return; } editor.dataset.conditionEditorReady = '1'; editor.addEventListener( 'click', function (event) { const removeButton = event.target.closest( '[data-remove-condition-node]' ); if (removeButton) { const node = removeButton.closest( '[data-condition-node]' ); if (node) { node.remove(); } return; } const addCondition = event.target.closest( '[data-add-condition]' ); const addGroup = event.target.closest( '[data-add-condition-group]' ); if ( !addCondition && !addGroup ) { return; } const group = event.target.closest( '[data-condition-type="group"]' ); if (!group) { return; } const children = Array .from( group.children ) .find( function (child) { return child.hasAttribute( 'data-condition-children' ); } ); if (!children) { return; } const groupTypeInput = Array .from( group.children ) .find( function (child) { return ( child.tagName === 'INPUT' && child.type === 'hidden' && child.name.endsWith( '[type]' ) ); } ); if (!groupTypeInput) { return; } const groupName = groupTypeInput.name.replace( /\[type\]$/, '' ); const nextIndex = wayfarerConditionNextChildIndex( group ); const childName = groupName + '[children][' + nextIndex + ']'; const newNode = addGroup ? wayfarerConditionCreateGroup( childName, targets, nextIndex, false ) : wayfarerConditionCreateCondition( childName, targets, nextIndex ); children.appendChild( newNode ); } ); } function initializeConditionEditors() { /* * WAYFARER_GENERIC_CONDITION_INIT_V45_2 * * The Condition Editor is shared infrastructure. It must work anywhere it * is rendered, not only when nested inside Tick Effects. * * Server-rendered editors carry their own allowed-target JSON. Dynamic * Tick Effect / Effect editors continue to initialize themselves through * their existing creation paths. */ document .querySelectorAll( '[data-condition-editor]' ) .forEach( function (editor) { let targets = {}; const targetJson = editor.querySelector( '[data-condition-editor-targets-json]' ); if (targetJson) { try { targets = JSON.parse( targetJson.textContent || '{}' ); } catch (error) { targets = {}; } } wayfarerConditionInitializeEditor( editor, targets ); } ); /* * Tick Effects retain their observer because newly-created Tick Effect * rows are added after page load and are not server-rendered editors. */ document .querySelectorAll( '[data-builder-tick-effects]' ) .forEach( function (wrapper) { let targets = {}; const targetJson = wrapper.querySelector( '[data-condition-targets-json]' ); if (targetJson) { try { targets = JSON.parse( targetJson.textContent || '{}' ); } catch (error) { targets = {}; } } wrapper .querySelectorAll( '[data-condition-editor]' ) .forEach( function (editor) { wayfarerConditionInitializeEditor( editor, targets ); } ); /* * New Tick Effect rows are cloned from a template after page * load, so convert their placeholder into a fresh root group. */ const list = wrapper.querySelector( '[data-tick-effects-list]' ); if ( list && !list.dataset.conditionObserverReady ) { list.dataset.conditionObserverReady = '1'; const observer = new MutationObserver( function (mutations) { mutations.forEach( function (mutation) { mutation.addedNodes .forEach( function (addedNode) { if ( !addedNode.querySelectorAll ) { return; } const row = addedNode.matches && addedNode.matches( '[data-tick-effect-row]' ) ? addedNode : addedNode.querySelector( '[data-tick-effect-row]' ); if (!row) { return; } const host = row.querySelector( '[data-new-condition-editor]' ); if (!host) { return; } const index = row.dataset.tickIndex; const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( 'tick_effects[' + index + '][conditions]', targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } ); } ); } ); observer.observe( list, { childList: true, subtree: true } ); } } ); } initializeConditionEditors(); // ================================================================ // TICK EFFECT RESULT BRANCHES v28 // WAYFARER_TICK_EFFECT_BRANCHES_V28 // ================================================================ function wayfarerTickEffectConditionTargets( wrapper ) { const targetJson = wrapper.querySelector( '[data-condition-targets-json]' ); if (!targetJson) { return {}; } try { return JSON.parse( targetJson.textContent || '{}' ); } catch (error) { return {}; } } function wayfarerTickEffectUpdateBranchValue( branch ) { const operation = branch.querySelector( '[data-tick-branch-operation]' ); const valueWrap = branch.querySelector( '[data-tick-branch-value-wrap]' ); if ( !operation || !valueWrap ) { return; } const input = valueWrap.querySelector( 'input' ); const noChange = operation.value === 'none'; valueWrap.hidden = noChange; if (input) { input.disabled = noChange; } } function wayfarerTickEffectReindexBranches( effectRow ) { const effectIndex = effectRow.dataset.tickIndex; const list = effectRow.querySelector( '[data-tick-branch-list]' ); if ( effectIndex === undefined || !list ) { return; } const branches = Array.from( list.children ).filter( function (child) { return ( child.matches && child.matches( '[data-tick-effect-branch]' ) ); } ); branches.forEach( function ( branch, branchIndex ) { branch.dataset.branchIndex = String( branchIndex ); branch .querySelectorAll( '[name]' ) .forEach( function (field) { field.name = field.name.replace( /tick_effects\[\d+\]\[branches\]\[\d+\]/, 'tick_effects[' + effectIndex + '][branches][' + branchIndex + ']' ); } ); } ); } function wayfarerTickEffectRefreshBranchControls( effectRow ) { const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } const branches = Array.from( list.children ).filter( function (child) { return ( child.matches && child.matches( '[data-tick-effect-branch]' ) ); } ); const conditionalBranches = branches.filter( function (branch) { return ( branch.dataset.branchKind === 'conditional' ); } ); conditionalBranches.forEach( function ( branch, index ) { const up = branch.querySelector( '[data-move-tick-branch-up]' ); const down = branch.querySelector( '[data-move-tick-branch-down]' ); if (up) { up.disabled = index === 0; } if (down) { down.disabled = index === conditionalBranches.length - 1; } } ); const hasOtherwise = branches.some( function (branch) { return ( branch.dataset.branchKind === 'otherwise' ); } ); const addOtherwise = effectRow.querySelector( '[data-add-tick-otherwise-result]' ); if (addOtherwise) { addOtherwise.disabled = hasOtherwise; } branches.forEach( wayfarerTickEffectUpdateBranchValue ); wayfarerTickEffectReindexBranches( effectRow ); } function wayfarerTickEffectBuildConditionEditor( host, name, targets ) { const editor = document.createElement( 'div' ); editor.className = 'builder-condition-editor'; editor.dataset.conditionEditor = '1'; editor.appendChild( wayfarerConditionCreateGroup( name, targets, null, true ) ); host.replaceWith( editor ); wayfarerConditionInitializeEditor( editor, targets ); } function initializeTickEffectResultBranches() { document .querySelectorAll( '[data-builder-tick-effects]' ) .forEach( function (wrapper) { if ( wrapper.dataset.tickBranchesReady === '1' ) { return; } wrapper.dataset.tickBranchesReady = '1'; const conditionalTemplate = wrapper.querySelector( '[data-tick-conditional-branch-template]' ); const otherwiseTemplate = wrapper.querySelector( '[data-tick-otherwise-branch-template]' ); const conditionTargets = wayfarerTickEffectConditionTargets( wrapper ); wrapper .querySelectorAll( '[data-tick-effect-row]' ) .forEach( wayfarerTickEffectRefreshBranchControls ); wrapper.addEventListener( 'change', function (event) { const operation = event.target.closest( '[data-tick-branch-operation]' ); if (!operation) { return; } const branch = operation.closest( '[data-tick-effect-branch]' ); if (branch) { wayfarerTickEffectUpdateBranchValue( branch ); } } ); wrapper.addEventListener( 'click', function (event) { const effectRow = event.target.closest( '[data-tick-effect-row]' ); if (!effectRow) { return; } const addConditional = event.target.closest( '[data-add-tick-conditional-result]' ); if (addConditional) { if (!conditionalTemplate) { return; } const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } const effectIndex = effectRow.dataset.tickIndex; const branchIndex = list.querySelectorAll( ':scope > [data-tick-effect-branch]' ).length; const html = conditionalTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } const otherwise = Array .from( list.children ) .find( function (child) { return ( child.dataset && child.dataset.branchKind === 'otherwise' ); } ); if (otherwise) { list.insertBefore( branch, otherwise ); } else { list.appendChild( branch ); } wayfarerTickEffectReindexBranches( effectRow ); const newIndex = branch.dataset.branchIndex; const host = branch.querySelector( '[data-new-branch-condition-editor]' ); if (host) { wayfarerTickEffectBuildConditionEditor( host, 'tick_effects[' + effectIndex + '][branches][' + newIndex + '][conditions]', conditionTargets ); } wayfarerTickEffectRefreshBranchControls( effectRow ); return; } const addOtherwise = event.target.closest( '[data-add-tick-otherwise-result]' ); if (addOtherwise) { if (!otherwiseTemplate) { return; } const list = effectRow.querySelector( '[data-tick-branch-list]' ); if (!list) { return; } if ( list.querySelector( '[data-branch-kind="otherwise"]' ) ) { return; } const effectIndex = effectRow.dataset.tickIndex; const branchIndex = list.querySelectorAll( ':scope > [data-tick-effect-branch]' ).length; const html = otherwiseTemplate.innerHTML .replaceAll( '__EFFECT_INDEX__', effectIndex ) .replaceAll( '__BRANCH_INDEX__', String( branchIndex ) ); const temporary = document.createElement( 'div' ); temporary.innerHTML = html.trim(); const branch = temporary.firstElementChild; if (!branch) { return; } list.appendChild( branch ); wayfarerTickEffectRefreshBranchControls( effectRow ); return; } const removeBranch = event.target.closest( '[data-remove-tick-branch]' ); if (removeBranch) { const branch = removeBranch.closest( '[data-tick-effect-branch]' ); if (branch) { branch.remove(); wayfarerTickEffectRefreshBranchControls( effectRow ); } return; } const moveUp = event.target.closest( '[data-move-tick-branch-up]' ); const moveDown = event.target.closest( '[data-move-tick-branch-down]' ); if ( !moveUp && !moveDown ) { return; } const branch = event.target.closest( '[data-tick-effect-branch]' ); const list = effectRow.querySelector( '[data-tick-branch-list]' ); if ( !branch || !list || branch.dataset.branchKind !== 'conditional' ) { return; } const conditionals = Array.from( list.children ).filter( function (child) { return ( child.dataset && child.dataset.branchKind === 'conditional' ); } ); const index = conditionals.indexOf( branch ); if ( moveUp && index > 0 ) { list.insertBefore( branch, conditionals[index - 1] ); } if ( moveDown && index >= 0 && index < conditionals.length - 1 ) { const next = conditionals[index + 1]; list.insertBefore( next, branch ); } wayfarerTickEffectRefreshBranchControls( effectRow ); } ); /* * Existing Tick Effect code creates whole effect rows from its * template. Refresh branch controls after those rows appear. */ const list = wrapper.querySelector( '[data-tick-effects-list]' ); if (list) { const observer = new MutationObserver( function (mutations) { mutations.forEach( function (mutation) { mutation.addedNodes .forEach( function (node) { if ( !node.matches || !node.matches( '[data-tick-effect-row]' ) ) { return; } wayfarerTickEffectRefreshBranchControls( node ); } ); } ); } ); observer.observe( list, { childList: true } ); } } ); } initializeTickEffectResultBranches(); ================================================================================ FILE: update-backups/v58-20260925-205136/assets/js/starting-choices.js ================================================================================ /* |-------------------------------------------------------------------------- | Starting Choices Editor |-------------------------------------------------------------------------- | WAYFARER_STARTING_CHOICES_V36 |-------------------------------------------------------------------------- */ (function () { const editor = document.querySelector( '[data-starting-choices-editor]' ); if (!editor) { return; } const list = editor.querySelector( '[data-starting-choices-list]' ); const choiceTemplate = editor.querySelector( '[data-starting-choice-template]' ); const optionTemplate = editor.querySelector( '[data-starting-option-template]' ); const effectTemplate = editor.querySelector( '[data-starting-effect-template]' ); function cloneTemplate(template) { if (!template) { return null; } const fragment = template.content.cloneNode( true ); return fragment.firstElementChild; } function reindex() { if (!list) { return; } Array .from( list.querySelectorAll( ':scope > [data-starting-choice]' ) ) .forEach( function ( choice, choiceIndex ) { choice .querySelectorAll( '[data-starting-choice-field]' ) .forEach( function (field) { field.name = 'setup_choices[' + choiceIndex + '][' + field.dataset.startingChoiceField + ']'; } ); Array .from( choice.querySelectorAll( ':scope > [data-starting-options-list] > [data-starting-option]' ) ) .forEach( function ( option, optionIndex ) { option .querySelectorAll( '[data-starting-option-field]' ) .forEach( function (field) { field.name = 'setup_choices[' + choiceIndex + '][options][' + optionIndex + '][' + field.dataset.startingOptionField + ']'; } ); Array .from( option.querySelectorAll( ':scope .builder-starting-effects > [data-starting-effects-list] > [data-starting-effect]' ) ) .forEach( function ( effect, effectIndex ) { effect .querySelectorAll( '[data-starting-effect-field]' ) .forEach( function (field) { field.name = 'setup_choices[' + choiceIndex + '][options][' + optionIndex + '][effects][' + effectIndex + '][' + field.dataset.startingEffectField + ']'; } ); } ); } ); } ); } const addChoice = editor.querySelector( '[data-add-starting-choice]' ); if (addChoice && list) { addChoice.addEventListener( 'click', function () { const choice = cloneTemplate( choiceTemplate ); if (!choice) { return; } list.appendChild( choice ); const optionList = choice.querySelector( '[data-starting-options-list]' ); const firstOption = cloneTemplate( optionTemplate ); if ( optionList && firstOption ) { optionList.appendChild( firstOption ); } reindex(); const prompt = choice.querySelector( '[data-starting-choice-field="prompt"]' ); if (prompt) { prompt.focus(); } } ); } editor.addEventListener( 'click', function (event) { const removeChoice = event.target.closest( '[data-remove-starting-choice]' ); if (removeChoice) { const choice = removeChoice.closest( '[data-starting-choice]' ); if ( choice && window.confirm( 'Remove this Starting Choice?' ) ) { choice.remove(); reindex(); } return; } const addOption = event.target.closest( '[data-add-starting-option]' ); if (addOption) { const choice = addOption.closest( '[data-starting-choice]' ); const optionList = choice ? choice.querySelector( '[data-starting-options-list]' ) : null; const option = cloneTemplate( optionTemplate ); if ( optionList && option ) { optionList.appendChild( option ); reindex(); const label = option.querySelector( '[data-starting-option-field="label"]' ); if (label) { label.focus(); } } return; } const removeOption = event.target.closest( '[data-remove-starting-option]' ); if (removeOption) { const option = removeOption.closest( '[data-starting-option]' ); if ( option && window.confirm( 'Remove this option?' ) ) { option.remove(); reindex(); } return; } const addEffect = event.target.closest( '[data-add-starting-effect]' ); if (addEffect) { const option = addEffect.closest( '[data-starting-option]' ); const effectsList = option ? option.querySelector( '[data-starting-effects-list]' ) : null; const effect = cloneTemplate( effectTemplate ); if ( effectsList && effect ) { effectsList.appendChild( effect ); reindex(); } return; } const removeEffect = event.target.closest( '[data-remove-starting-effect]' ); if (removeEffect) { const effect = removeEffect.closest( '[data-starting-effect]' ); if (effect) { effect.remove(); reindex(); } } } ); reindex(); })(); ================================================================================ FILE: update-backups/v58-20260925-205136/includes/gameplay/condition-editor.php ================================================================================ Select target...'; $grouped = []; foreach ($targets as $key => $target) { if (!is_array($target)) { continue; } $label = (string)( $target['label'] ?? $key ); $group = (string)( $target['group'] ?? 'Targets' ); if (!isset($grouped[$group])) { $grouped[$group] = []; } $grouped[$group][$key] = $label; } foreach ($grouped as $group => $options) { echo ''; foreach ($options as $value => $label) { echo ''; } echo ''; } } function wayfarerConditionEditorRenderNode( $node, string $name, array $targets, bool $isRoot = false ): void { if ( !is_array( $node ) || ( ($node['type'] ?? '') !== 'group' && ($node['type'] ?? '') !== 'condition' ) ) { $node = wayfarerConditionEmptyGroup( 'all' ); } $type = (string)( $node['type'] ?? 'group' ); if ($type === 'condition') { $targetKey = (string)( $node['scope'] ?? '' ) . ':' . (string)( $node['target_type'] ?? '' ) . ':' . (string)( $node['target_id'] ?? '' ); $operator = (string)( $node['operator'] ?? 'equals' ); $value = $node['value'] ?? ''; ?>
of the following
$child ): ?> '; wayfarerConditionEditorRenderNode( $child, $name . '[children][' . $childIndex . ']', $targets, false ); echo '
'; ?>
================================================================================ FILE: update-backups/v58-20260925-205136/includes/gameplay/effects-editor.php ================================================================================ operation/value * OTHERWISE -> operation/value * * First matching result wins. Each result may use a Fixed value or roll a * Random Min-Max value once when the Effect executes. */ $gameplayEffects = isset( $gameplayEffects ) && is_array( $gameplayEffects ) ? array_values( $gameplayEffects ) : []; $gameplayGameTraitTargets = $gameplayGameTraitTargets ?? []; $gameplayScopeTraitTargets = $gameplayScopeTraitTargets ?? []; $gameplayScopeKey = (string)( $gameplayScopeKey ?? '' ); $gameplayScopeLabel = (string)( $gameplayScopeLabel ?? '' ); $gameplayAllowEffectNavigation = !empty( $gameplayAllowEffectNavigation ); $gameplayEffectDescription = (string)( $gameplayEffectDescription ?? 'These Effects run once when this object is triggered.' ); $gameplayConditionTargets = isset( $gameplayConditionTargets ) && is_array( $gameplayConditionTargets ) ? $gameplayConditionTargets : []; /* WAYFARER_UNIVERSAL_EFFECT_TARGET_EDITOR_V56 */ $gameplayTargetCatalog = isset($gameplayTargetCatalog) && is_array($gameplayTargetCatalog) ? $gameplayTargetCatalog : []; $renderEffectTargetOptions = function ( array $effect = [] ) use ( $gameplayAllowEffectNavigation, $gameplayGameTraitTargets, $gameplayScopeKey, $gameplayScopeLabel, $gameplayScopeTraitTargets, $gameplayTargetCatalog ): void { echo ''; if ($gameplayTargetCatalog) { $selectedKey = (string)( $effect['target_scope'] ?? '' ) . ':' . (string)( $effect['target_type'] ?? '' ) . ':' . (string)( $effect['target_id'] ?? '' ); $grouped = []; foreach ( $gameplayTargetCatalog as $value => $target ) { if (!is_array($target)) { continue; } $group = (string)( $target['group'] ?? 'Targets' ); if (!isset($grouped[$group])) { $grouped[$group] = []; } $grouped[$group][$value] = $target; } foreach ($grouped as $group => $options) { echo ''; foreach ($options as $value => $target) { echo ''; } echo ''; } return; } if ($gameplayAllowEffectNavigation) { echo ''; echo ''; echo ''; } if ($gameplayGameTraitTargets) { echo ''; foreach ( $gameplayGameTraitTargets as $targetTrait ) { echo ''; } echo ''; } if ( $gameplayScopeKey !== '' && $gameplayScopeTraitTargets ) { echo ''; foreach ( $gameplayScopeTraitTargets as $targetTrait ) { echo ''; } echo ''; } }; $renderEffectResultFields = function ( array $branch, string $name ): void { $operation = gameplayEffectBranchOperation( $branch ); $valueMode = gameplayEffectBranchValueMode( $branch ); $value = gameplayEffectBranchValue( $branch ); $minimum = gameplayEffectBranchMin( $branch ); $maximum = gameplayEffectBranchMax( $branch ); $noChange = $operation === 'none'; ?>
>

Effects

Each Effect controls one target. Results are checked from top to bottom; the first matching result wins. Random values are rolled once when the Effect fires.

$effect ): ?>
$branch ): ?>
THEN

================================================================================ FILE: update-backups/v58-20260925-205136/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $normalized['value'] = $branch['value'] ?? ''; } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect: * operation/value/conditions become one branch. * * No conditions -> OTHERWISE. * Existing conditions -> conditional branch. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'conditions', $effect ) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $branch['value'] = $effect['value'] ?? ''; } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( !in_array( $operation, [ 'add', 'subtract', 'set', 'none', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Effect result operation.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) ) { return [ 'effects' => [], 'error' => 'Effect value type must be Fixed or Random.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( ( $operation === 'add' || $operation === 'subtract' ) && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Add/Subtract Effect results require a numeric value.', ]; } $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( !in_array( $operation, [ 'add', 'subtract', 'set', 'none', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result operation.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); /* WAYFARER_TICK_EFFECT_SCALAR_VALUES_V56 */ if ( ( $operation === 'add' || $operation === 'subtract' ) && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Add/Subtract Tick Effect results require a numeric value.', ]; } $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } $targetDefinition = $allowedTargets[$target]; /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ), 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => wayfarerConditionNormalizeScalar( $value ), ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v58-20260925-205136/includes/gameplay/starting-choices.php ================================================================================ prepare( " SELECT id, name, config FROM wf_entities WHERE game_id = ? AND status = 'active' AND LOWER(entity_type) = 'item' ORDER BY name, id " ); $stmt->execute([ $gameId ]); $rows = $stmt->fetchAll( PDO::FETCH_ASSOC ); $targets = []; foreach ($rows as $row) { $entityId = (int)( $row['id'] ?? 0 ); if ($entityId <= 0) { continue; } $config = []; if ( isset($row['config']) && $row['config'] !== null && $row['config'] !== '' ) { $decoded = json_decode( (string)$row['config'], true ); if (is_array($decoded)) { $config = $decoded; } } /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Quantity is universal for Item Entities. Its definition is Wayfarer * schema, while this Item's initial/max values live in config.system. */ $quantityTraitId = 'sys_item_quantity'; $targets[] = [ 'entity_id' => $entityId, 'name' => trim( (string)( $row['name'] ?? ( 'Item ' . $entityId ) ) ), 'target_scope' => 'entity:' . $entityId, 'target_type' => 'trait', 'target_id' => $quantityTraitId, 'target_system_key' => 'quantity', ]; } return $targets; } function wayfarerStartingChoiceEffectTarget( array $effect ): string { if (!empty($effect['target'])) { return (string)$effect['target']; } $scope = (string)( $effect['target_scope'] ?? '' ); $type = (string)( $effect['target_type'] ?? '' ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); if ( $scope !== '' && $type !== '' && $targetId !== '' ) { $canonical = $scope . ':' . $type . ':' . $targetId; $runtimeTargets = $GLOBALS[ 'wayfarer_starting_runtime_targets' ] ?? []; if ( is_array($runtimeTargets) && isset($runtimeTargets[$canonical]) ) { return $canonical; } } /* * Legacy setup targets remain readable. */ if ( $scope === 'game' && $type === 'trait' && $targetId !== '' ) { return 'game:' . $targetId; } if ( $scope === 'player' && $type === 'trait' && $targetId !== '' ) { $selector = strtolower( trim( (string)( $effect['target_selector'] ?? '' ) ) ); if ($selector === 'all') { return 'player_all:' . $targetId; } if ( $selector === 'slot' && array_key_exists( 'target_slot', $effect ) ) { return 'player_slot:' . max( 0, (int)$effect['target_slot'] ) . ':' . $targetId; } } if ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope ) === 1 ) { return $scope . ':' . $targetId; } return ''; } /* * Build the complete target map used by both authoring validation and the * Starting Choice target selector. */ function wayfarerStartingChoiceAllowedTargets( array $gameTraits, array $playerTraits = [], int $playerSlots = 0, array $itemQuantityTargets = [], array $runtimeTargets = [] ): array { /* WAYFARER_STARTING_CHOICES_UNIVERSAL_TARGETS_V56 */ if ($runtimeTargets) { return $runtimeTargets; } $allowed = []; foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $trait ) { $traitId = (string)( $trait['id'] ?? '' ); if ($traitId === '') { continue; } $allowed[ 'game:' . $traitId ] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $traitId, 'group' => 'Game Traits', 'label' => 'Game → ' . (string)( $trait['name'] ?? $traitId ), ]; } $playerTargets = gameplayNumericTraitTargets( $playerTraits, 'player' ); $playerSlots = max( 0, $playerSlots ); if ( $playerSlots > 0 && !empty($playerTargets) ) { foreach ($playerTargets as $trait) { $traitId = (string)( $trait['id'] ?? '' ); if ($traitId === '') { continue; } $allowed[ 'player_all:' . $traitId ] = [ 'target_scope' => 'player', 'target_type' => 'trait', 'target_id' => $traitId, 'target_selector' => 'all', 'group' => 'Player Traits — All Players', 'label' => 'All Players → ' . (string)( $trait['name'] ?? $traitId ), ]; } for ( $slot = 0; $slot < $playerSlots; $slot++ ) { foreach ($playerTargets as $trait) { $traitId = (string)( $trait['id'] ?? '' ); if ($traitId === '') { continue; } $allowed[ 'player_slot:' . $slot . ':' . $traitId ] = [ 'target_scope' => 'player', 'target_type' => 'trait', 'target_id' => $traitId, 'target_selector' => 'slot', 'target_slot' => $slot, 'group' => 'Player Character ' . ($slot + 1), 'label' => 'Player ' . ($slot + 1) . ' → ' . (string)( $trait['name'] ?? $traitId ), ]; } } } foreach ($itemQuantityTargets as $itemTarget) { if (!is_array($itemTarget)) { continue; } $scope = trim( (string)( $itemTarget['target_scope'] ?? '' ) ); $targetId = trim( (string)( $itemTarget['target_id'] ?? '' ) ); $entityId = (int)( $itemTarget['entity_id'] ?? 0 ); if ( $entityId <= 0 || !preg_match( '/^entity:[1-9][0-9]*$/', $scope ) || $targetId === '' ) { continue; } $key = $scope . ':' . $targetId; $allowed[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $targetId, 'target_system_key' => 'quantity', 'group' => 'Items', 'label' => (string)( $itemTarget['name'] ?? ( 'Item ' . $entityId ) ) . ' → Quantity', ]; } return $allowed; } function wayfarerBuildStartingChoicesFromPost( array $postedChoices, array $gameTraits, array $playerTraits = [], int $playerSlots = 0, array $itemQuantityTargets = [], array $runtimeTargets = [] ): array { $allowedTargets = wayfarerStartingChoiceAllowedTargets( $gameTraits, $playerTraits, $playerSlots, $itemQuantityTargets, $runtimeTargets ); /* * Legacy migration map: * * V52 allowed authors to move a definition from Game Traits to Player * Traits while keeping its Trait ID, but V36 Starting Choices still held * game:TRAIT_ID. When that exact ID is now a Player Trait, interpret the * old target as All Player Characters. */ $legacyPlayerTraitTargets = []; foreach ( gameplayNumericTraitTargets( $playerTraits, 'player' ) as $trait ) { $traitId = (string)( $trait['id'] ?? '' ); if ($traitId === '') { continue; } $legacyPlayerTraitTargets[ 'game:' . $traitId ] = 'player_all:' . $traitId; } $choices = []; $seenChoiceIds = []; foreach ( array_values($postedChoices) as $choiceIndex => $postedChoice ) { if (!is_array($postedChoice)) { continue; } $prompt = trim( (string)( $postedChoice['prompt'] ?? '' ) ); $postedOptions = isset($postedChoice['options']) && is_array($postedChoice['options']) ? array_values( $postedChoice['options'] ) : []; /* * Completely blank newly-added card: ignore it. */ if ( $prompt === '' && $postedOptions === [] ) { continue; } if ($prompt === '') { return [ 'choices' => [], 'error' => 'Each Starting Choice must have a prompt.', ]; } $choiceId = wayfarerStartingChoiceValidId( $postedChoice['id'] ?? null ) ? (string)$postedChoice['id'] : wayfarerStartingChoiceMakeId( 'choice' ); if (isset($seenChoiceIds[$choiceId])) { return [ 'choices' => [], 'error' => 'Starting Choice IDs must be unique.', ]; } $seenChoiceIds[$choiceId] = true; $required = !empty( $postedChoice['required'] ); $options = []; $seenOptionIds = []; foreach ( $postedOptions as $optionIndex => $postedOption ) { if (!is_array($postedOption)) { continue; } $label = trim( (string)( $postedOption['label'] ?? '' ) ); $description = trim( (string)( $postedOption['description'] ?? '' ) ); $postedEffects = isset($postedOption['effects']) && is_array($postedOption['effects']) ? array_values( $postedOption['effects'] ) : []; if ( $label === '' && $description === '' && $postedEffects === [] ) { continue; } if ($label === '') { return [ 'choices' => [], 'error' => 'Each Starting Choice option must have a label.', ]; } $optionId = wayfarerStartingChoiceValidId( $postedOption['id'] ?? null ) ? (string)$postedOption['id'] : wayfarerStartingChoiceMakeId( 'option' ); if (isset($seenOptionIds[$optionId])) { return [ 'choices' => [], 'error' => 'Starting Choice option IDs must be unique within a choice.', ]; } $seenOptionIds[$optionId] = true; $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $operation = trim( (string)( $postedEffect['operation'] ?? '' ) ); $valueRaw = trim( (string)( $postedEffect['value'] ?? '' ) ); /* * Empty newly-added effect row. */ if ( $target === '' && $operation === '' && $valueRaw === '' ) { continue; } /* * Preserve the intent of a Trait moved from Game -> Player. */ if ( !isset($allowedTargets[$target]) && isset( $legacyPlayerTraitTargets[$target] ) && isset( $allowedTargets[ $legacyPlayerTraitTargets[$target] ] ) ) { $target = $legacyPlayerTraitTargets[$target]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'choices' => [], 'error' => 'Each Starting Choice effect must use a valid runtime-state target.', ]; } if ( !in_array( $operation, [ 'add', 'subtract', 'set', ], true ) ) { return [ 'choices' => [], 'error' => 'Starting Choice effects must Add, Subtract, or Set.', ]; } if ( ( $operation === 'add' || $operation === 'subtract' ) && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'choices' => [], 'error' => 'Starting Choice Add/Subtract effects require a numeric value.', ]; } $value = is_numeric($valueRaw) ? ( filter_var( $valueRaw, FILTER_VALIDATE_INT ) !== false ? (int)$valueRaw : (float)$valueRaw ) : $valueRaw; $targetDefinition = $allowedTargets[$target]; $effect = [ 'target_scope' => (string)$targetDefinition['target_scope'], 'target_type' => (string)( $targetDefinition['target_type'] ?? 'trait' ), 'target_id' => (string)$targetDefinition['target_id'], 'operation' => $operation, 'value' => $value, ]; if ( !empty( $targetDefinition[ 'target_system_key' ] ) ) { $effect['target_system_key'] = (string)$targetDefinition[ 'target_system_key' ]; } if ( $targetDefinition['target_scope'] === 'player' ) { $effect['target_selector'] = (string)( $targetDefinition[ 'target_selector' ] ?? 'all' ); if ( $effect['target_selector'] === 'slot' ) { $effect['target_slot'] = (int)( $targetDefinition[ 'target_slot' ] ?? 0 ); } } $effects[] = $effect; } $options[] = [ 'id' => $optionId, 'label' => $label, 'description' => $description, 'effects' => $effects, ]; } if ($options === []) { return [ 'choices' => [], 'error' => 'Each Starting Choice must have at least one option.', ]; } $choices[] = [ 'id' => $choiceId, 'prompt' => $prompt, 'required' => $required, 'options' => $options, ]; } return [ 'choices' => $choices, 'error' => '', ]; } function wayfarerRenderStartingChoiceTargetOptions( array $gameTraits, array $playerTraits = [], int $playerSlots = 0, array $itemQuantityTargets = [], string $selected = '', array $runtimeTargets = [] ): void { $targets = wayfarerStartingChoiceAllowedTargets( $gameTraits, $playerTraits, $playerSlots, $itemQuantityTargets, $runtimeTargets ); /* * If an existing option still stores game:TRAIT_ID but that ID now lives * under Player Traits, present it as All Players instead of losing the * selection in the editor. */ if ( $selected !== '' && !isset($targets[$selected]) && strpos( $selected, 'game:' ) === 0 ) { $traitId = substr( $selected, strlen('game:') ); $candidate = 'player_all:' . $traitId; if (isset($targets[$candidate])) { $selected = $candidate; } } echo ''; $grouped = []; foreach ($targets as $value => $target) { $group = (string)( $target['group'] ?? 'Traits' ); if (!isset($grouped[$group])) { $grouped[$group] = []; } $grouped[$group][$value] = $target; } foreach ($grouped as $group => $options) { echo ''; foreach ($options as $value => $target) { echo ''; } echo ''; } } function wayfarerRenderStartingChoiceEffect( array $effect = [] ): void { $target = wayfarerStartingChoiceEffectTarget( $effect ); $operation = (string)( $effect['operation'] ?? 'set' ); $value = $effect['value'] ?? ''; ?>
Option
Starting Effects Optional. Effects may target numeric Game Traits or Player Traits.
Starting Choice

Starting Choices

Optional one-of-many questions shown before the game begins. The selected option is saved in runtime state. Option effects can update any addressable runtime-state target during initialization.

Select target...'; if ($gameplayTargetCatalog) { $selectedKey = (string)( $tickEffect['target_scope'] ?? '' ) . ':' . (string)( $tickEffect['target_type'] ?? '' ) . ':' . (string)( $tickEffect['target_id'] ?? '' ); $grouped = []; foreach ( $gameplayTargetCatalog as $value => $target ) { if (!is_array($target)) { continue; } $group = (string)( $target['group'] ?? 'Targets' ); if (!isset($grouped[$group])) { $grouped[$group] = []; } $grouped[$group][$value] = $target; } foreach ($grouped as $group => $options) { echo ''; foreach ($options as $value => $target) { echo ''; } echo ''; } return; } if ($gameplayAllowNavigation) { echo ''; echo ''; echo ''; } if ($gameplayGameTraitTargets) { echo ''; foreach ( $gameplayGameTraitTargets as $targetTrait ) { echo ''; } echo ''; } if ( $gameplayScopeKey !== '' && $gameplayScopeTraitTargets ) { echo ''; foreach ( $gameplayScopeTraitTargets as $targetTrait ) { echo ''; } echo ''; } }; $renderBranchResultFields = function ( array $branch, string $name ): void { $operation = gameplayTickEffectBranchOperation( $branch ); $value = gameplayTickEffectBranchValue( $branch ); ?>
data-gameplay-scope="" data-navigation-type="" >

Tick Effects

Each Tick Effect controls one target. Its results are checked from top to bottom. The first matching result wins within that Tick Effect. Separate Tick Effects remain independent.

$tickEffect ): ?>
$branch ): ?>
THEN

================================================================================ FILE: update-backups/v58-20260925-205136/play/includes/effects.php ================================================================================ true, 'value' => wayfarerNormalizeRuntimeNumber( (float)$effectValue ), 'error' => '', ]; } return [ 'applied' => true, 'value' => $effectValue, 'error' => '', ]; } if ( $operation !== 'add' && $operation !== 'subtract' ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Unsupported operation: ' . $operation, ]; } if ( !is_numeric($currentValue) || !is_numeric($effectValue) ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Add/subtract requires numeric values.', ]; } $current = (float)$currentValue; $amount = (float)$effectValue; $result = $operation === 'add' ? $current + $amount : $current - $amount; return [ 'applied' => true, 'value' => wayfarerNormalizeRuntimeNumber( $result ), 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Tick Effect branch runtime |-------------------------------------------------------------------------- | | A Tick Effect owns one target and an ordered list of result branches. | Conditions are evaluated against the CURRENT runtime state. Because Tick | Effects execute sequentially, a later Tick Effect can see state changes made | by an earlier Tick Effect during the same tick. | */ function wayfarerRuntimeResolveConditionTarget( array $runtimeState, int $currentRegionId, string $scope, string $targetType, string $targetId ): array { $scope = strtolower( trim( $scope ) ); $targetType = strtolower( trim( $targetType ) ); $targetId = trim( $targetId ); // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $targetType === 'trait' && $targetId !== '' ) { $traits = $runtimeState['game']['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // SPECIFIC REGION TRAIT // WAYFARER_SPECIFIC_REGION_CONDITION_SCOPE_V46 // ------------------------------------------------------------ // // Game-level condition editors store a Region-owned Trait as // "region:". This lets a global rule inspect that Region whether // or not the player is currently there. // if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^region:([1-9][0-9]*)$/', $scope, $regionScopeMatch ) === 1 ) { $regionKey = (string)( (int)$regionScopeMatch[1] ); $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $targetType === 'trait' && $targetId !== '' && $currentRegionId > 0 ) { $regionKey = (string)$currentRegionId; $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // PLAYER / LOCATION / ENTITY TRAITS // WAYFARER_RUNTIME_UNIVERSAL_TARGET_RESOLVER_V56 // ------------------------------------------------------------ if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^player:([0-9]+)$/', $scope, $playerScopeMatch ) === 1 ) { $playerKey = (string)((int)$playerScopeMatch[1]); $traits = $runtimeState['players'][$playerKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^location:([1-9][0-9]*)$/', $scope, $locationScopeMatch ) === 1 ) { $locationKey = (string)((int)$locationScopeMatch[1]); $traits = $runtimeState['locations'][$locationKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityScopeMatch ) === 1 ) { $entityKey = (string)((int)$entityScopeMatch[1]); $traits = $runtimeState['entities'][$entityKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $targetType === 'position' && $targetId === 'position' ) { if ( isset( $runtimeState['navigation'] ) && is_array( $runtimeState['navigation'] ) && array_key_exists( 'position', $runtimeState['navigation'] ) ) { return [ 'found' => true, 'value' => $runtimeState['navigation']['position'], ]; } return [ 'found' => false, 'value' => null, ]; } return [ 'found' => false, 'value' => null, ]; } function wayfarerRuntimeTickEffectBranches( array $effect ): array { /* * v28+ canonical structure. * * If the branches key exists, it is authoritative. An empty branches * array therefore means "this effect has no executable results" rather * than silently falling back to legacy compatibility fields. */ if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * Legacy pre-v28 structure. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'conditions', $effect ) ) { return [ [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $effect['operation'] ?? '', 'value' => $effect['value'] ?? 0, ] ]; } return []; } function wayfarerRuntimeSelectTickEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeTickEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Tick Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; /* * Validate before evaluating. Invalid authored data fails closed and * does NOT fall through to OTHERWISE, because doing so could silently * produce the wrong gameplay result. */ $validation = wayfarerConditionValidateNode( $conditions ); if (!$validation['valid']) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Tick Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Runtime Trait bounds |-------------------------------------------------------------------------- | | WAYFARER_RUNTIME_TRAIT_BOUNDS_V30 | | Authored numeric Trait min/max values are runtime invariants. | State is normalized before condition evaluation and every subsequent | mutation is clamped again before it is written. | */ function wayfarerRuntimeDecodeTraitConfig( $rawConfig ): array { if (is_array($rawConfig)) { return $rawConfig; } if ( $rawConfig === null || $rawConfig === '' ) { return []; } $decoded = json_decode( (string)$rawConfig, true ); return is_array($decoded) ? $decoded : []; } function wayfarerRuntimeIndexTraitDefinitions( array $traits ): array { $definitions = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $definitions[$traitId] = $trait; } return $definitions; } function wayfarerRuntimeLoadTickTraitDefinitions( PDO $pdo, int $gameId, array $regionConfig ): array { $gameConfig = []; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawGameConfig = $stmt->fetchColumn(); if ($rawGameConfig !== false) { $gameConfig = wayfarerRuntimeDecodeTraitConfig( $rawGameConfig ); } } /* WAYFARER_RUNTIME_UNIVERSAL_DEFINITIONS_V56 */ $definitions = [ 'game' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['traits'] ?? null ) ? $gameConfig['traits'] : [] ), 'player' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['player_traits'] ?? null ) ? $gameConfig['player_traits'] : [] ), 'region' => wayfarerRuntimeIndexTraitDefinitions( is_array( $regionConfig['traits'] ?? null ) ? $regionConfig['traits'] : [] ), 'regions' => [], 'locations' => [], 'entities' => [], ]; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $definitions['regions'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } $stmt = $pdo->prepare( " SELECT l.id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $definitions['locations'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } $stmt = $pdo->prepare( " SELECT id, entity_type, config FROM wf_entities WHERE game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); if ( function_exists( 'wayfarerEntityActiveTraitDefinitions' ) ) { $definitions['entities'][$id] = wayfarerEntityActiveTraitDefinitions( $config, (string)( $row['entity_type'] ?? '' ) ); } else { $definitions['entities'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } } } return $definitions; } function wayfarerRuntimeClampValueToTraitDefinition( $value, ?array $definition ): array { $result = [ 'valid' => true, 'value' => $value, 'clamped' => false, 'min' => null, 'max' => null, 'error' => '', ]; /* * Bounds currently belong only to authored Number Traits. */ if ( !$definition || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' ) { return $result; } if (!is_numeric($value)) { $result['valid'] = false; $result['error'] = 'Numeric Trait runtime state must be numeric.'; return $result; } $min = isset($definition['min']) && $definition['min'] !== '' && is_numeric($definition['min']) ? (float)$definition['min'] : null; $max = isset($definition['max']) && $definition['max'] !== '' && is_numeric($definition['max']) ? (float)$definition['max'] : null; $numeric = (float)$value; $bounded = $numeric; if ( $min !== null && $bounded < $min ) { $bounded = $min; } if ( $max !== null && $bounded > $max ) { $bounded = $max; } $result['value'] = wayfarerNormalizeRuntimeNumber( $bounded ); $result['clamped'] = $bounded !== $numeric; $result['min'] = $min === null ? null : wayfarerNormalizeRuntimeNumber( $min ); $result['max'] = $max === null ? null : wayfarerNormalizeRuntimeNumber( $max ); return $result; } function wayfarerRuntimeApplyBoundedTraitOperation( $storedBefore, string $operation, $effectValue, ?array $definition ): array { /* * Repair an already-illegal persisted value BEFORE applying the new * operation. Example: 120 with max 100 and subtract 10 becomes * 100 - 10 = 90, not 120 - 10 = 110 -> 100. */ $beforeBound = wayfarerRuntimeClampValueToTraitDefinition( $storedBefore, $definition ); if (!$beforeBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $storedBefore, 'requested_after' => $storedBefore, 'after' => $storedBefore, 'clamped' => false, 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $beforeBound['error'], ]; } $normalizedBefore = $beforeBound['value']; $operationResult = wayfarerApplyRuntimeOperation( $normalizedBefore, $operation, $effectValue ); if (!$operationResult['applied']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $normalizedBefore, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $operationResult['error'], ]; } $requestedAfter = $operationResult['value']; $afterBound = wayfarerRuntimeClampValueToTraitDefinition( $requestedAfter, $definition ); if (!$afterBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $afterBound['error'], ]; } return [ 'applied' => true, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $afterBound['value'], 'clamped' => $beforeBound['clamped'] || $afterBound['clamped'], 'min' => $afterBound['min'], 'max' => $afterBound['max'], 'error' => '', ]; } function wayfarerRuntimeClampApplicableTraitState( array &$runtimeState, array $traitDefinitions, int $currentRegionId ): array { $repairs = []; // ------------------------------------------------------------ // GAME TRAITS // ------------------------------------------------------------ $gameTraits = $runtimeState['game']['traits'] ?? null; if (is_array($gameTraits)) { foreach ( $traitDefinitions['game'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $gameTraits ) ) { continue; } $before = $runtimeState['game']['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['game']['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } // ------------------------------------------------------------ // CURRENT REGION TRAITS // ------------------------------------------------------------ $regionKey = (string)$currentRegionId; $regionTraits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if (is_array($regionTraits)) { foreach ( $traitDefinitions['region'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $regionTraits ) ) { continue; } $before = $runtimeState['regions'][$regionKey]['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } return $repairs; } function wayfarerApplyRuntimeTickEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectTickEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection['branch_index'], 'applied' => false, 'error' => $selection['error'], ]; } /* * No conditional branch matched and there was no OTHERWISE. * This is a valid "do nothing this tick" result. */ if (!$selection['matched']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection['branch_index']; $operation = strtolower( trim( (string)( $branch['operation'] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; /* * Explicit No Change: * the branch DID match, so this Tick Effect stops here, but state does * not mutate. */ if ($operation === 'none') { $current = wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $type, $targetId ); return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'amount' => null, 'before' => $current['found'] ? $current['value'] : null, 'after' => $current['found'] ? $current['value'] : null, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'applied' => false, 'no_change' => true, 'error' => '', ]; } /* * Apply ONLY the selected result branch. The legacy effect-level * operation/value fields are deliberately ignored when branches exist. */ $resolvedEffect = $effect; $resolvedEffect['operation'] = $operation; $resolvedEffect['value'] = $branch['value'] ?? 0; $report = wayfarerApplyRuntimeEffect( $runtimeState, $resolvedEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; return $report; } function wayfarerApplyRuntimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); $operation = strtolower( trim( (string)( $effect['operation'] ?? '' ) ) ); $effectValue = $effect['value'] ?? 0; // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $type === 'position' && $targetId === 'position' ) { if ( !isset($runtimeState['navigation']) || !is_array($runtimeState['navigation']) ) { $runtimeState['navigation'] = []; } $before = $runtimeState['navigation']['position'] ?? 0; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); $after = $before; if ( $result['applied'] && !is_numeric($result['value']) ) { $result['applied'] = false; $result['error'] = 'Navigation position must be numeric.'; } if ($result['applied']) { /* * Global navigation rule: * position may never be less than zero. */ $after = max( 0, $result['value'] ); $runtimeState['navigation']['position'] = $after; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $after, 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['game']) || !is_array($runtimeState['game']) ) { $runtimeState['game'] = []; } if ( !isset($runtimeState['game']['traits']) || !is_array($runtimeState['game']['traits']) ) { $runtimeState['game']['traits'] = []; } $before = $runtimeState['game']['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['game'][$targetId] ) && is_array( $traitDefinitions['game'][$targetId] ) ? $traitDefinitions['game'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); /* * Persist the normalized value even when a malformed operation fails * after an existing illegal value was repaired. */ if ( $result['normalized_before'] !== $before ) { $runtimeState['game']['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['game']['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // ITEM ENTITY QUANTITY // WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 // ------------------------------------------------------------ if ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityScopeMatch ) === 1 && ( (string)( $effect['target_system_key'] ?? '' ) === 'quantity' || $targetId === 'sys_item_quantity' ) ) { $entityKey = (string)( (int)$entityScopeMatch[1] ); if ( !isset( $runtimeState['entities'][$entityKey] ) || !is_array( $runtimeState['entities'][$entityKey] ) || !isset( $runtimeState['entities'][$entityKey]['traits'] ) || !is_array( $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Entity runtime state was not initialized.', ]; } if ( !array_key_exists( $targetId, $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Quantity runtime state was not found.', ]; } $before = $runtimeState['entities'][$entityKey]['traits'][$targetId]; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); if ( !$result['applied'] || !is_numeric( $result['value'] ?? null ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $before, 'applied' => false, 'error' => $result['error'] !== '' ? $result['error'] : 'Item Quantity must remain numeric.', ]; } $requestedAfter = wayfarerNormalizeRuntimeNumber( (float)$result['value'] ); /* * Quantity is globally non-negative. */ $after = wayfarerNormalizeRuntimeNumber( max( 0.0, (float)$requestedAfter ) ); $runtimeState['entities'][$entityKey]['traits'][$targetId] = $after; /* * Discovery is persistent. Receiving any positive starting quantity * discovers the Item; setting it back to zero never undiscoveres it. */ if ( (float)$after > 0 ) { $runtimeState['entities'][$entityKey]['discovered'] = true; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_system_key' => 'quantity', 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'requested_after' => $requestedAfter, 'after' => $after, 'clamped' => (float)$after !== (float)$requestedAfter, 'min' => 0, 'max' => null, 'applied' => true, 'error' => '', ]; } // ------------------------------------------------------------ // SPECIFIC PLAYER / REGION / LOCATION / ENTITY TRAITS // WAYFARER_RUNTIME_UNIVERSAL_EFFECT_TARGETS_V56 // ------------------------------------------------------------ $stateBucket = null; $stateKey = null; $definition = null; if ( $type === 'trait' && $targetId !== '' && preg_match( '/^player:([0-9]+)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'players'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['player'][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^region:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'regions'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['regions'][$stateKey][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^location:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'locations'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['locations'][$stateKey][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 && $targetId !== 'sys_item_quantity' ) { $stateBucket = 'entities'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['entities'][$stateKey][$targetId] ?? null; } if ( $stateBucket !== null && $stateKey !== null ) { if ( !isset($runtimeState[$stateBucket][$stateKey]) || !is_array($runtimeState[$stateBucket][$stateKey]) || !isset($runtimeState[$stateBucket][$stateKey]['traits']) || !is_array($runtimeState[$stateBucket][$stateKey]['traits']) || !array_key_exists( $targetId, $runtimeState[$stateBucket][$stateKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Runtime Trait target was not found.', ]; } $before = $runtimeState[$stateBucket][$stateKey]['traits'][$targetId]; /* * Party Membership is mutable runtime state, but still a binary * Wayfarer system property. The 0/1 rule is enforced internally and is * never exposed as a separate Target/max metadata value. */ if ( $stateBucket === 'entities' && $targetId === 'sys_character_party_member' ) { $operationResult = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); if ( !$operationResult['applied'] || !is_numeric( $operationResult['value'] ?? null ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $before, 'applied' => false, 'error' => $operationResult['error'] !== '' ? $operationResult['error'] : 'Party Membership requires a numeric 0/1 result.', ]; } $after = (float)$operationResult['value'] > 0 ? 1 : 0; $runtimeState[$stateBucket][$stateKey]['traits'][$targetId] = $after; return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_system_key' => 'party_member', 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $after, 'applied' => true, 'error' => '', ]; } $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, is_array($definition) ? $definition : null ); if ($result['applied']) { $runtimeState[$stateBucket][$stateKey]['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // PLAYER TRAIT // WAYFARER_STARTING_CHOICE_PLAYER_TARGETS_V53 // ------------------------------------------------------------ if ( $scope === 'player' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['players']) || !is_array($runtimeState['players']) ) { $runtimeState['players'] = []; } $selector = strtolower( trim( (string)( $effect['target_selector'] ?? '' ) ) ); $targetKeys = []; if ($selector === 'all') { foreach ( $runtimeState['players'] as $playerKey => $playerState ) { if (is_array($playerState)) { $targetKeys[] = (string)$playerKey; } } } elseif ( $selector === 'slot' && array_key_exists( 'target_slot', $effect ) ) { $slot = max( 0, (int)$effect['target_slot'] ); $targetKeys[] = (string)$slot; } else { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect requires an All Players or Player Slot target.', ]; } if (empty($targetKeys)) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect did not resolve any Player Characters.', ]; } $definition = isset( $traitDefinitions['player'][$targetId] ) && is_array( $traitDefinitions['player'][$targetId] ) ? $traitDefinitions['player'][$targetId] : null; $results = []; $firstError = ''; foreach ($targetKeys as $playerKey) { if ( !isset( $runtimeState['players'][$playerKey] ) || !is_array( $runtimeState['players'][$playerKey] ) ) { $firstError = 'Player Character slot ' . ((int)$playerKey + 1) . ' does not exist in runtime state.'; $results[$playerKey] = [ 'applied' => false, 'error' => $firstError, ]; break; } if ( !isset( $runtimeState['players'][$playerKey]['traits'] ) || !is_array( $runtimeState['players'][$playerKey]['traits'] ) ) { $runtimeState['players'][$playerKey]['traits'] = []; } $before = $runtimeState['players'][$playerKey]['traits'][$targetId] ?? 0; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['after']; } $results[$playerKey] = [ 'slot' => (int)$playerKey, 'name' => (string)( $runtimeState['players'][$playerKey]['name'] ?? ( 'Player ' . ((int)$playerKey + 1) ) ), 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; if ( !$result['applied'] || $result['error'] !== '' ) { $firstError = $result['error'] !== '' ? $result['error'] : 'Player Trait effect could not be applied.'; break; } } $allApplied = $firstError === ''; return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_selector' => $selector, 'target_slot' => $selector === 'slot' ? (int)( $effect['target_slot'] ?? 0 ) : null, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'results' => $results, 'applied' => $allApplied, 'error' => $firstError, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $type === 'trait' && $targetId !== '' ) { $regionKey = (string)$currentRegionId; if ( !isset($runtimeState['regions']) || !is_array($runtimeState['regions']) ) { $runtimeState['regions'] = []; } if ( !isset($runtimeState['regions'][$regionKey]) || !is_array($runtimeState['regions'][$regionKey]) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset($runtimeState['regions'][$regionKey]['traits']) || !is_array($runtimeState['regions'][$regionKey]['traits']) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } $before = $runtimeState['regions'][$regionKey]['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['region'][$targetId] ) && is_array( $traitDefinitions['region'][$targetId] ) ? $traitDefinitions['region'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Unsupported runtime target.', ]; } /* |-------------------------------------------------------------------------- | Game Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_GAME_TICK_EFFECT_RUNTIME_V33 | | Game Tick Effects execute once on every successful runtime tick before | the active Region's Tick Effects. This creates a deterministic layering: | | Game rules -> Region rules | */ function wayfarerApplyGameTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { if ($gameId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active game is available for Game Tick Effects.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { $reports = [ [ 'applied' => false, 'error' => 'Game ' . $gameId . ' was not found.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $gameConfig = []; if ( $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, [] ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $gameEffects = $gameConfig['tick_effects'] ?? []; if (!is_array($gameEffects)) { $gameEffects = []; } $reports = []; foreach ($gameEffects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Game Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; $runtimeState['runtime']['last_game_trait_bound_repairs'] = $boundRepairs; return $reports; } /* |-------------------------------------------------------------------------- | Generalized one-time Effect runtime | WAYFARER_GENERALIZED_EFFECT_RUNTIME_V43 |-------------------------------------------------------------------------- | | Conditions use the same shared evaluator as Tick Effect branches. | Random values are resolved exactly once, immediately before the selected | result mutates runtime state. | */ function wayfarerRuntimeEffectBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * v42 compatibility: one Effect was one unconditional operation/value. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $branch = [ 'conditions' => null, 'operation' => $effect['operation'] ?? 'add', 'value_mode' => $effect['value_mode'] ?? 'fixed', ]; if ( strtolower( trim( (string)( $branch[ 'value_mode' ] ) ) ) === 'random' ) { $branch['value_min'] = $effect['value_min'] ?? 0; $branch['value_max'] = $effect['value_max'] ?? 0; } else { $branch['value'] = $effect['value'] ?? 0; } return [ $branch ]; } return []; } function wayfarerRuntimeSelectEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; $validation = wayfarerConditionValidateNode( $conditions ); if ( !$validation['valid'] ) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } function wayfarerRuntimeRollEffectValue( array $branch ): array { $mode = strtolower( trim( (string)( $branch[ 'value_mode' ] ?? 'fixed' ) ) ); if ($mode !== 'random') { $value = $branch['value'] ?? null; if (!is_numeric($value)) { return [ 'valid' => false, 'mode' => 'fixed', 'value' => null, 'min' => null, 'max' => null, 'error' => 'Fixed Effect value must be numeric.', ]; } return [ 'valid' => true, 'mode' => 'fixed', 'value' => wayfarerNormalizeRuntimeNumber( (float)$value ), 'min' => null, 'max' => null, 'error' => '', ]; } $minimum = $branch['value_min'] ?? null; $maximum = $branch['value_max'] ?? null; if ( !is_numeric($minimum) || !is_numeric($maximum) ) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => $minimum, 'max' => $maximum, 'error' => 'Random Effect requires numeric Min and Max values.', ]; } $min = (float)$minimum; $max = (float)$maximum; if ($min > $max) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => 'Random Effect Min cannot be greater than Max.', ]; } if ( floor($min) === $min && floor($max) === $max ) { $rolled = random_int( (int)$min, (int)$max ); } else { /* * Continuous decimal range. Six decimal places of entropy are more * than sufficient for authored simulation values and preserve generic * numeric Min/Max support without requiring authored precision metadata. */ $fraction = random_int( 0, 1000000 ) / 1000000; $rolled = $min + (($max - $min) * $fraction); } return [ 'valid' => true, 'mode' => 'random', 'value' => wayfarerNormalizeRuntimeNumber( (float)$rolled ), 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => '', ]; } function wayfarerApplyRuntimeOneTimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect[ 'target_scope' ] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect[ 'target_type' ] ?? '' ) ) ); $targetId = trim( (string)( $effect[ 'target_id' ] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection[ 'branch_index' ], 'applied' => false, 'error' => $selection[ 'error' ], ]; } if ( !$selection['matched'] ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection[ 'branch_index' ]; $operation = strtolower( trim( (string)( $branch[ 'operation' ] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; if ($operation === 'none') { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => null, 'rolled_value' => null, 'applied' => false, 'error' => '', ]; } $resolved = wayfarerRuntimeRollEffectValue( $branch ); if (!$resolved['valid']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => $resolved[ 'mode' ], 'rolled_value' => null, 'random_min' => $resolved[ 'min' ], 'random_max' => $resolved[ 'max' ], 'applied' => false, 'error' => $resolved[ 'error' ], ]; } $concreteEffect = [ 'target_scope' => $scope, 'target_type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'value' => $resolved[ 'value' ], ]; $report = wayfarerApplyRuntimeEffect( $runtimeState, $concreteEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; $report['value_mode'] = $resolved[ 'mode' ]; $report['rolled_value'] = $resolved[ 'value' ]; if ( $resolved['mode'] === 'random' ) { $report['random_min'] = $resolved[ 'min' ]; $report['random_max'] = $resolved[ 'max' ]; } return $report; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECT_RUNTIME_V42 | | These are event Effects, not Tick Effects. The caller decides that a | Location has been triggered and invokes this function exactly once for that | trigger. Effects execute top-to-bottom against current runtime state. | */ function wayfarerApplyLocationEffects( PDO $pdo, int $gameId, array $location, array &$runtimeState ): array { $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeTraitConfig( $location['config'] ?? null ); $effects = $locationConfig['effects'] ?? []; if (!is_array($effects)) { $effects = []; } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false ) { $regionConfig = wayfarerRuntimeDecodeTraitConfig( $rawRegionConfig ); } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeOneTimeEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_location_effects'] = [ 'location_id' => $locationId, 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), 'reports' => $reports, 'bound_repairs' => $boundRepairs, ]; return $reports; } /* |-------------------------------------------------------------------------- | Current Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECT_RUNTIME_V35 | | Linear Regions resolve a current Location by exact authored Position, | matching the player-scene resolver. Location Tick Effects run after Region | Tick Effects, so movement performed by the Region can cause arrival effects | at the newly reached Location on the same tick. | */ function wayfarerRuntimeFindCurrentLocation( PDO $pdo, int $gameId, array $runtimeState ): ?array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ( $gameId <= 0 || $regionId <= 0 ) { return null; } $position = $runtimeState['navigation']['position'] ?? null; if (!is_numeric($position)) { return null; } $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($locations as $location) { $config = []; if ( !empty( $location['config'] ) ) { $decoded = json_decode( (string)$location['config'], true ); if (is_array($decoded)) { $config = $decoded; } } if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } if ( (float)$config['position'] === (float)$position ) { $location['_runtime_config'] = $config; /* * WAYFARER_LOCATION_CONDITIONS_CURRENT_V45 */ if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } return $location; } } return null; } function wayfarerApplyCurrentLocationTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } /* * Being between authored Locations is normal. Record an empty result * rather than treating the absence of a Location as an error. */ if (!$location) { $runtimeState['runtime']['last_location_id'] = null; $runtimeState['runtime']['last_location_tick_effects'] = []; return []; } $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : []; $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); /* * Location effects may target Current Region Traits, so load the active * Region's Trait definitions rather than the Location's authored Traits. */ $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false && $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $locationEffects = $locationConfig['tick_effects'] ?? []; if (!is_array($locationEffects)) { $locationEffects = []; } $reports = []; foreach ( $locationEffects as $effect ) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } $runtimeState['runtime']['last_location_id'] = $locationId; $runtimeState['runtime']['last_location_tick_effects'] = $reports; $runtimeState['runtime']['last_location_trait_bound_repairs'] = $boundRepairs; return $reports; } function wayfarerApplyCurrentRegionTickEffects( PDO $pdo, array &$runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ($regionId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active navigation region.', ] ]; /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Reports are returned for the current request, not persisted. */ return $reports; } $stmt = $pdo->prepare( " SELECT id, game_id, config FROM wf_regions WHERE id = ? LIMIT 1 " ); $stmt->execute([ $regionId ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { $reports = [ [ 'applied' => false, 'error' => 'Active Region ' . $regionId . ' was not found.', ] ]; /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Reports are returned for the current request, not persisted. */ return $reports; } $config = []; if (!empty($region['config'])) { $decoded = json_decode( $region['config'], true ); if (is_array($decoded)) { $config = $decoded; } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, (int)( $region['game_id'] ?? 0 ), $config ); /* * Enforce the invariant before any branch condition is evaluated. * This also repairs state produced by older runtime versions. */ $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $regionId ); $effects = $config['tick_effects'] ?? []; if (!is_array($effects)) { $effects = []; } $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed tick effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $regionId, $traitDefinitions ); } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Tick reports and bound-repair reports are immediate diagnostics only. */ return $reports; } ================================================================================ FILE: update-backups/v58-20260925-205136/play/includes/runtime.php ================================================================================ prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? ORDER BY id " ); $stmt->execute([ $gameId ]); $regions = $stmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($regions as $region) { $regionId = (int)( $region['id'] ?? 0 ); if ($regionId <= 0) { continue; } $regionKey = (string)$regionId; $config = wayfarerRuntimeDecodeConfig( $region['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset( $runtimeState['regions'][$regionKey] ) || !is_array( $runtimeState['regions'][$regionKey] ) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset( $runtimeState['regions'][$regionKey]['traits'] ) || !is_array( $runtimeState['regions'][$regionKey]['traits'] ) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['regions'][$regionKey]['traits'] ) ) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $initialValue; } } } /* |-------------------------------------------------------------------------- | Persisted state for every Location | WAYFARER_ALL_LOCATION_STATE_V56 |-------------------------------------------------------------------------- | | Locations can own Traits, so universal Conditions/Effects need persistent | per-playthrough Location Trait state just like Regions. | */ function wayfarerRuntimeEnsureAllLocationTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['locations']) || !is_array($runtimeState['locations']) ) { $runtimeState['locations'] = []; } $stmt = $pdo->prepare( " SELECT l.id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $location ) { $locationId = (int)($location['id'] ?? 0); if ($locationId <= 0) { continue; } $locationKey = (string)$locationId; $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset($runtimeState['locations'][$locationKey]) || !is_array($runtimeState['locations'][$locationKey]) ) { $runtimeState['locations'][$locationKey] = []; } if ( !isset($runtimeState['locations'][$locationKey]['traits']) || !is_array($runtimeState['locations'][$locationKey]['traits']) ) { $runtimeState['locations'][$locationKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['locations'][$locationKey]['traits'] ) ) { continue; } $runtimeState['locations'][$locationKey]['traits'][$traitId] = $initialValue; } } } function wayfarerRuntimeLoadStartingRegion( PDO $pdo, int $gameId, ?array $gameConfig = null ): ?array { if ($gameId <= 0) { return null; } if ($gameConfig === null) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { return null; } $gameConfig = wayfarerRuntimeDecodeConfig( $rawConfig ); } $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT * FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $startingRegionId, $gameId, ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); return $region ?: null; } function wayfarerRuntimeInitializeGame( PDO $pdo, int $gameUserId, array $setupInput = [] ): array { if ($gameUserId <= 0) { throw new RuntimeException( 'A valid game-user ID is required to initialize the game.' ); } try { $pdo->beginTransaction(); /* * Lock the playthrough row so two initialize requests cannot create * competing starting states. */ $stmt = $pdo->prepare( " SELECT gu.id, gu.game_id, gu.user_id, gu.role, gu.state, g.config AS game_config, g.player_slots AS player_slots FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id WHERE gu.id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } /* * Initialization is intentionally one-way. To restart a development * game, clear wf_game_users.state to NULL first. */ if ( $row['state'] !== null && $row['state'] !== '' ) { throw new RuntimeException( 'This game-user already has runtime state.' ); } $gameId = (int)$row['game_id']; $gameConfig = wayfarerRuntimeDecodeConfig( $row['game_config'] ?? null ); $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { throw new RuntimeException( 'This Game does not have a Starting Region configured.' ); } $startingRegion = wayfarerRuntimeLoadStartingRegion( $pdo, $gameId, $gameConfig ); if (!$startingRegion) { throw new RuntimeException( 'The configured Starting Region does not exist for this Game.' ); } $regionConfig = wayfarerRuntimeDecodeConfig( $startingRegion['config'] ?? null ); $gameTraits = wayfarerRuntimeInitialTraitState( $gameConfig ); $regionTraits = wayfarerRuntimeInitialTraitState( $regionConfig ); $navigation = [ 'region_id' => (int)$startingRegion['id'], ]; /* * Linear travel currently begins at coordinate zero. * Other navigation modes will get their own authored start-state * rules when those modes are implemented. */ if ( strtolower( trim( (string)( $startingRegion['navigation_type'] ?? '' ) ) ) === 'linear' ) { $navigation['position'] = 0; } $regionKey = (string)$startingRegion['id']; $state = [ /* * WAYFARER_RUNTIME_SCHEMA_V55 * Version the persisted playthrough contract explicitly. */ 'schema_version' => 1, 'game' => [ 'traits' => $gameTraits, ], 'navigation' => $navigation, 'regions' => [ $regionKey => [ 'traits' => $regionTraits, ], ], 'runtime' => [ 'tick' => 0, ], ]; /* * WAYFARER_ALL_REGION_STATE_V46 * * Global conditions can inspect Region state before that Region has * ever been visited. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $state ); /* WAYFARER_INITIALIZE_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_V49 * * Items and Characters receive canonical per-playthrough state before * any starting setup effects are applied. */ wayfarerRuntimeEnsureEntityState( $pdo, $gameId, $state ); /* * WAYFARER_APPLY_STARTING_CHOICES_V36 * * Validate all player-entered setup data before persisting anything. * Then apply the selected options' starting effects to the canonical * initial runtime state. */ $preparedSetup = wayfarerRuntimePrepareStartingSetup( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $setupInput ); /* * WAYFARER_PLAYER_TRAITS_RUNTIME_V52 * * Persist the validated player-character names before creating the * per-player Trait state. Each slot receives independent values. */ $state['setup'] = [ 'player_names' => $preparedSetup['player_names'], 'choices' => $preparedSetup['choices'], ]; wayfarerRuntimeEnsureGameTraitState( $gameConfig, $state ); wayfarerRuntimeEnsurePlayerState( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $state ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $startingEffectReports = []; foreach ( $preparedSetup['effects'] as $startingEffect ) { $report = wayfarerApplyRuntimeEffect( $state, $startingEffect, (int)$startingRegion['id'], $traitDefinitions ); if ( !empty($report['error']) ) { throw new RuntimeException( 'Unable to apply a Starting Choice effect: ' . $report['error'] ); } $startingEffectReports[] = $report; } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * * Starting-effect reports are intentionally not persisted. The effects * themselves have already mutated canonical state. */ /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 * * A game that begins exactly on an active Location should present that * Location before ordinary Action Mode. */ $startingLocation = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $state ); if ($startingLocation) { /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Starting on a Location is a real Location trigger. Apply its * one-time Effects before opening Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, $gameId, $startingLocation, $state ); $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$startingLocation['id'], 'phase' => 'presentation', 'trigger' => 'initial', 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /* WAYFARER_GAME_ENDINGS_INITIAL_V45 */ wayfarerRuntimeEvaluateGameEndings( $pdo, $gameId, $state ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => $gameId, 'starting_region_id' => (int)$startingRegion['id'], 'state' => $state, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Resolve a game-user record for the tick prototype. * * Preferred: * explicit wf_game_users.id * * Temporary development fallback: * first wf_game_users record. * * The fallback exists only because Wayfarer has not yet wired the play * page to authentication / game selection. Once that exists, remove the * fallback and always pass the active game_user_id explicitly. */ function wayfarerRuntimeResolveGameUserId( PDO $pdo, ?int $requestedId = null ): int { if ($requestedId !== null && $requestedId > 0) { $stmt = $pdo->prepare( " SELECT id FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $requestedId ]); $id = $stmt->fetchColumn(); if ($id !== false) { return (int)$id; } throw new RuntimeException( 'The requested game-user record does not exist.' ); } $id = $pdo ->query( " SELECT id FROM wf_game_users ORDER BY id LIMIT 1 " ) ->fetchColumn(); if ($id === false) { throw new RuntimeException( 'No wf_game_users record exists.' ); } return (int)$id; } /** * Load one game-user runtime state. */ function wayfarerRuntimeLoad( PDO $pdo, int $gameUserId ): array { $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'state' => $state, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /** * Advance exactly one tick. * * This is intentionally the entire v14 game engine: * * state.runtime.tick += 1 * * The transaction and row lock are already in place so later work can add * movement/effects without changing the request architecture. */ /* |-------------------------------------------------------------------------- | Linear Location crossing | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | A tick may move farther than the distance to the next authored Location. | This resolver finds the FIRST active Location crossed in the direction of | travel. The movement is then clamped to that exact authored coordinate. | | Examples: | 45 -> 55, Location 50 => stop at 50 | 55 -> 45, Location 50 => stop at 50 | | The starting coordinate is intentionally exclusive so leaving a Location | does not immediately trigger that same Location again. | */ /* |-------------------------------------------------------------------------- | Location Conditions | WAYFARER_LOCATION_CONDITIONS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeLocationConditionsPass( PDO $pdo, int $gameId, array $location, array &$runtimeState ): bool { $config = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $conditions = $config['conditions'] ?? null; $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; return wayfarerConditionsPass( $conditions, $resolver ); } /* |-------------------------------------------------------------------------- | Game Endings | WAYFARER_GAME_ENDINGS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeEvaluateGameEndings( PDO $pdo, int $gameId, array &$runtimeState ): ?array { /* * WAYFARER_ALL_REGION_STATE_ENDINGS_V46 */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $runtimeState ); /* WAYFARER_RECOVER_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $runtimeState ); if ( isset( $runtimeState['runtime']['ending'] ) && is_array( $runtimeState['runtime']['ending'] ) ) { return $runtimeState['runtime']['ending']; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); $config = wayfarerRuntimeDecodeConfig( $raw === false ? null : $raw ); $endings = $config['endings'] ?? []; if (!is_array($endings)) { return null; } $ordered = []; foreach ( array_values($endings) as $index => $ending ) { if (!is_array($ending)) { continue; } $ending['_runtime_order'] = $index; $ordered[] = $ending; } usort( $ordered, function ( array $a, array $b ): int { $priorityCompare = (int)( $b['priority'] ?? 0 ) <=> (int)( $a['priority'] ?? 0 ); if ($priorityCompare !== 0) { return $priorityCompare; } return (int)( $a['_runtime_order'] ?? 0 ) <=> (int)( $b['_runtime_order'] ?? 0 ); } ); $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; foreach ($ordered as $ending) { if ( !wayfarerConditionsPass( $ending['conditions'] ?? null, $resolver ) ) { continue; } $terminal = [ 'id' => (string)( $ending['id'] ?? '' ), 'title' => (string)( $ending['title'] ?? 'The End' ), 'text' => (string)( $ending['text'] ?? '' ), 'image_media_id' => (int)( $ending['image_media_id'] ?? 0 ), 'priority' => (int)( $ending['priority'] ?? 0 ), 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), ]; if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['ending'] = $terminal; unset( $runtimeState['runtime']['presentation'] ); return $terminal; } return null; } function wayfarerRuntimeFindCrossedLocation( PDO $pdo, int $gameId, int $regionId, $fromPosition, $toPosition, array &$runtimeState ): ?array { /* * WAYFARER_CROSSED_LOCATION_STATE_FIX_V46_1 * * Location Conditions are evaluated against the live runtime state. */ if ( $gameId <= 0 || $regionId <= 0 || !is_numeric($fromPosition) || !is_numeric($toPosition) ) { return null; } $from = (float)$fromPosition; $to = (float)$toPosition; if ($from === $to) { return null; } /* * Crossing interception currently applies only to Linear Regions. */ $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); $forward = $to > $from; $candidate = null; $candidatePosition = null; foreach ($locations as $location) { $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $locationPosition = (float)$config['position']; /* * The starting point is exclusive; the destination is inclusive. */ $crossed = $forward ? ( $locationPosition > $from && $locationPosition <= $to ) : ( $locationPosition < $from && $locationPosition >= $to ); if (!$crossed) { continue; } $location['_runtime_config'] = $config; if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } if ($candidate === null) { $candidate = $location; $candidatePosition = $locationPosition; } else { $nearer = $forward ? ( $locationPosition < $candidatePosition ) : ( $locationPosition > $candidatePosition ); $samePositionLowerId = $locationPosition === $candidatePosition && (int)$location['id'] < (int)$candidate['id']; if ( $nearer || $samePositionLowerId ) { $candidate = $location; $candidatePosition = $locationPosition; } } } if ($candidate === null) { return null; } $candidate['_runtime_position'] = wayfarerNormalizeRuntimeNumber( (float)$candidatePosition ); $candidate['_runtime_config'] = wayfarerRuntimeDecodeConfig( $candidate['config'] ?? null ); return $candidate; } /* |-------------------------------------------------------------------------- | Player spatial context |-------------------------------------------------------------------------- | WAYFARER_PLAYER_CONTEXT_V46_1 | | This is the canonical player-facing spatial label for Action Mode: | | exact eligible Location -> Location name + Region parent context | otherwise -> Region name | | It is returned by runtime APIs so the browser does not keep displaying the | Location name from the page's original server render after movement. | */ function wayfarerRuntimePlayerContext( PDO $pdo, int $gameId, array $runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionName = ''; if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $rawRegionName = $stmt->fetchColumn(); if ($rawRegionName !== false) { $regionName = trim( (string)$rawRegionName ); } } $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); $locationId = 0; $locationName = ''; if ($location) { $locationId = (int)( $location['id'] ?? 0 ); $locationName = trim( (string)( $location['name'] ?? '' ) ); } $contextName = $locationName !== '' ? $locationName : ( $regionName !== '' ? $regionName : 'Actions' ); return [ 'region_id' => $regionId, 'region_name' => $regionName, 'location_id' => $locationId, 'location_name' => $locationName, 'context_name' => $contextName, 'parent_context' => $locationName !== '' ? $regionName : '', ]; } /* |-------------------------------------------------------------------------- | Presentation dismissal |-------------------------------------------------------------------------- | | Presentation is persisted runtime state. This function is intentionally | generic even though v40 renders only Location presentations; Storylets can | use the same mechanism later. | */ function wayfarerRuntimeDismissPresentation( PDO $pdo, int $gameUserId, string $expectedType = '', int $expectedId = 0 ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); $presentation = $state['runtime']['presentation'] ?? null; if ( !is_array($presentation) || empty($presentation['type']) || empty($presentation['id']) ) { throw new RuntimeException( 'There is no active presentation to continue.' ); } if ( $expectedType !== '' && (string)$presentation['type'] !== $expectedType ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } if ( $expectedId > 0 && (int)$presentation['id'] !== $expectedId ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } $state['runtime']['last_presentation'] = $presentation; unset( $state['runtime']['presentation'] ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'dismissed' => $presentation, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Advance exactly one tick. * * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ function wayfarerRuntimeAdvanceTick( PDO $pdo, int $gameUserId ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } /* * WAYFARER_GAME_ENDINGS_TERMINAL_V45 * * Endings are terminal runtime state. */ if ( isset( $state['runtime']['ending'] ) && is_array( $state['runtime']['ending'] ) ) { throw new RuntimeException( 'This game has ended.' ); } /* * Narrative presentation is a true runtime pause, not merely a hidden * browser control. Even Author Mode cannot advance ticks through it. */ if ( isset( $state['runtime']['presentation'] ) && is_array( $state['runtime']['presentation'] ) ) { throw new RuntimeException( 'Continue the current presentation before advancing time.' ); } /* * WAYFARER_ALL_REGION_STATE_TICK_V46 * * Recovery path for playthroughs initialized before v46, and for * Regions/Traits authored after a playthrough began. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_LOCATION_STATE_TICK_RECOVERY_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, (int)$row['game_id'], $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_TICK_V49 * * Recovery path for playthroughs created before V49 and for Entities * or Traits authored after play began. */ wayfarerRuntimeEnsureEntityState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_PLAYER_TRAITS_TICK_RECOVERY_V52 */ wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$row['game_id'], $state ); $regionIdBeforeTick = (int)( $state['navigation']['region_id'] ?? 0 ); $positionBeforeTick = $state['navigation']['position'] ?? null; $currentTick = (int)( $state['runtime']['tick'] ?? 0 ); $state['runtime']['tick'] = $currentTick + 1; /* * Global Game Tick Effects run first. */ wayfarerApplyGameTickEffects( $pdo, (int)$row['game_id'], $state ); /* * Current Region Tick Effects then perform Region-level movement and * state changes. */ wayfarerApplyCurrentRegionTickEffects( $pdo, $state ); /* * Resolve whether the completed movement would have crossed an active * Location. If so, stop at the nearest crossed Location rather than * accepting the overshot position. */ $regionIdAfterMovement = (int)( $state['navigation']['region_id'] ?? 0 ); $positionAfterMovement = $state['navigation']['position'] ?? null; $crossedLocation = null; /* * We only interpret movement as a continuous path while the player * remains in the same Region. Region transitions will later get their * own explicit navigation semantics. */ if ( $regionIdBeforeTick > 0 && $regionIdBeforeTick === $regionIdAfterMovement ) { $crossedLocation = wayfarerRuntimeFindCrossedLocation( $pdo, (int)$row['game_id'], $regionIdAfterMovement, $positionBeforeTick, $positionAfterMovement, $state ); } $arrivalPosition = null; if ($crossedLocation) { $arrivalPosition = $crossedLocation[ '_runtime_position' ]; $state['navigation']['position'] = $arrivalPosition; /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Event Effects execute once at the trigger boundary. They run * before the Location's Tick Effects and before Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, (int)$row['game_id'], $crossedLocation, $state ); } /* * WAYFARER_LOCATION_EFFECTS_ONLY_RUNTIME_V43 * * Locations no longer own Tick Effects. Game and Region Tick Effects * have already advanced the simulation; a crossed Location contributes * its one-time Effects and then interrupts with Presentation Mode. */ if ($crossedLocation) { $state['navigation']['position'] = $arrivalPosition; $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$crossedLocation['id'], 'phase' => 'presentation', 'trigger' => 'arrival', 'tick' => (int)$state['runtime']['tick'], ]; $state['runtime']['last_location_arrival'] = [ 'location_id' => (int)$crossedLocation['id'], 'region_id' => $regionIdAfterMovement, 'position' => $arrivalPosition, 'tick' => (int)$state['runtime']['tick'], ]; } /* WAYFARER_GAME_ENDINGS_AFTER_TICK_V45 */ $ending = wayfarerRuntimeEvaluateGameEndings( $pdo, (int)$row['game_id'], $state ); $state['runtime']['last_tick_at'] = gmdate('c'); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId ]); $pdo->commit(); $presentation = $state['runtime']['presentation'] ?? null; $pausedForPresentation = is_array($presentation) && !empty($presentation['type']) && !empty($presentation['id']); $ending = $state['runtime']['ending'] ?? null; $ended = is_array($ending) && !empty($ending['id']); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'tick' => (int)$state['runtime']['tick'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'pause' => $pausedForPresentation || $ended, 'pause_reason' => $ended ? 'Game ended.' : ( $pausedForPresentation ? 'Location reached.' : '' ), 'presentation' => $pausedForPresentation ? $presentation : null, 'ending' => $ended ? $ending : null, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: update-backups/v59-20260925-211316/play/api/presentation.php ================================================================================ false, 'error' => 'POST required.', ] ); } if ( session_status() !== PHP_SESSION_ACTIVE ) { session_start(); } require_once __DIR__ . '/../../config/database.php'; require_once __DIR__ . '/../includes/runtime.php'; try { $rawInput = file_get_contents( 'php://input' ); $input = json_decode( $rawInput ?: '{}', true ); if (!is_array($input)) { wayfarerPresentationRespond( 400, [ 'ok' => false, 'error' => 'Invalid JSON request.', ] ); } $csrfToken = isset($input['csrf_token']) ? (string)$input['csrf_token'] : ''; $sessionToken = isset( $_SESSION[ 'wayfarer_tick_csrf' ] ) ? (string)$_SESSION[ 'wayfarer_tick_csrf' ] : ''; if ( $csrfToken === '' || $sessionToken === '' || !hash_equals( $sessionToken, $csrfToken ) ) { wayfarerPresentationRespond( 403, [ 'ok' => false, 'error' => 'Invalid runtime token.', ] ); } $requestedId = isset( $input['game_user_id'] ) ? (int)$input[ 'game_user_id' ] : null; $gameUserId = wayfarerRuntimeResolveGameUserId( $pdo, $requestedId ); $expectedType = trim( (string)( $input['type'] ?? '' ) ); $expectedId = (int)( $input['id'] ?? 0 ); $result = wayfarerRuntimeDismissPresentation( $pdo, $gameUserId, $expectedType, $expectedId ); wayfarerPresentationRespond( 200, [ 'ok' => true, 'game_user_id' => $result[ 'game_user_id' ], 'state' => $result['state'], /* WAYFARER_PLAYER_CONTEXT_API_V46_1 */ 'context' => $result['context'] ?? null, 'dismissed' => $result['dismissed'], ] ); } catch (Throwable $exception) { wayfarerPresentationRespond( 500, [ 'ok' => false, 'error' => $exception->getMessage(), ] ); } ================================================================================ FILE: update-backups/v59-20260925-211316/play/api/tick.php ================================================================================ false, 'error' => 'POST required.', ]); exit; } if (session_status() !== PHP_SESSION_ACTIVE) { session_start(); } require_once __DIR__ . '/../../config/database.php'; require_once __DIR__ . '/../includes/runtime.php'; function wayfarerTickRespond( int $status, array $payload ): void { http_response_code($status); echo json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); exit; } try { $rawInput = file_get_contents( 'php://input' ); $input = json_decode( $rawInput ?: '{}', true ); if (!is_array($input)) { wayfarerTickRespond( 400, [ 'ok' => false, 'error' => 'Invalid JSON request.', ] ); } $csrfToken = isset($input['csrf_token']) ? (string)$input['csrf_token'] : ''; $sessionToken = isset($_SESSION['wayfarer_tick_csrf']) ? (string)$_SESSION['wayfarer_tick_csrf'] : ''; if ( $csrfToken === '' || $sessionToken === '' || !hash_equals( $sessionToken, $csrfToken ) ) { wayfarerTickRespond( 403, [ 'ok' => false, 'error' => 'Invalid tick token.', ] ); } $requestedId = isset($input['game_user_id']) ? (int)$input['game_user_id'] : null; $gameUserId = wayfarerRuntimeResolveGameUserId( $pdo, $requestedId ); $result = wayfarerRuntimeAdvanceTick( $pdo, $gameUserId ); wayfarerTickRespond( 200, [ 'ok' => true, 'tick' => $result['tick'], 'game_user_id' => $result['game_user_id'], 'state' => $result['state'], /* WAYFARER_PLAYER_CONTEXT_API_V46_1 */ 'context' => $result['context'] ?? null, /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 * The browser stops automatic advancement immediately when the * runtime reports an interrupting presentation. */ 'pause' => !empty( $result['pause'] ), 'pause_reason' => (string)( $result['pause_reason'] ?? '' ), 'presentation' => $result['presentation'] ?? null, /* WAYFARER_GAME_ENDINGS_TICK_API_V45 */ 'ending' => $result['ending'] ?? null, ] ); } catch (Throwable $exception) { wayfarerTickRespond( 500, [ 'ok' => false, 'error' => $exception->getMessage(), ] ); } ================================================================================ FILE: update-backups/v59-20260925-211316/play/assets/play.css ================================================================================ /* |-------------------------------------------------------------------------- | Wayfarer Play Shell v20 |-------------------------------------------------------------------------- */ html, body { height: 100%; } body.wayfarer-play-page { margin: 0; overflow: hidden; background: #eef1f5; color: #20242a; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.45; } .wayfarer-play-page * { box-sizing: border-box; } .wayfarer-play-page hr { display: none; } .wayfarer-play-shell { height: 100vh; display: flex; flex-direction: column; overflow: hidden; } .wayfarer-play-shell-header { flex: 0 0 auto; min-height: 68px; padding: 12px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; background: #ffffff; border-bottom: 1px solid #d8dbe2; } .wayfarer-play-title { min-width: 0; } .wayfarer-play-title h1 { margin: 0; font-size: 1.35rem; line-height: 1.2; } .wayfarer-play-title p { margin: 4px 0 0; color: #667085; font-size: 0.9rem; } .wayfarer-shell-actions { display: flex; align-items: center; gap: 8px; } .wayfarer-shell-button { border: 1px solid #b8bdc7; border-radius: 6px; padding: 7px 10px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 600; } .wayfarer-shell-button:hover { background: #f6f7f9; } .wayfarer-play-main { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 7fr) minmax(330px, 3fr); gap: 12px; padding: 12px; overflow: hidden; } .wayfarer-player-pane, .wayfarer-author-pane { min-width: 0; min-height: 0; background: #ffffff; border: 1px solid #d8dbe2; border-radius: 10px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); } .wayfarer-player-pane { overflow: auto; padding: 22px; } .wayfarer-player-pane > h2:first-child { margin-top: 0; } #wayfarer-player-scene { margin-top: 10px; } #wayfarer-player-scene h2 { margin-top: 0; } .wayfarer-author-pane { display: flex; flex-direction: column; overflow: hidden; } .wayfarer-author-header { flex: 0 0 auto; padding: 14px 16px 10px; border-bottom: 1px solid #e3e6eb; } .wayfarer-author-header h2 { margin: 0; font-size: 1.05rem; } .wayfarer-author-header p { margin: 6px 0 0; font-size: 0.85rem; } .wayfarer-author-header a { color: #2f5ea8; } .wayfarer-author-toolbar { flex: 0 0 auto; padding: 10px 14px; background: #f6f7f9; border-bottom: 1px solid #e3e6eb; } .wayfarer-author-toolbar #wayfarer-tick-test { margin: 0; } .wayfarer-author-toolbar #wayfarer-tick-test > h2 { margin: 0 0 6px; font-size: 0.9rem; } .wayfarer-author-toolbar #wayfarer-tick-test > p { margin: 5px 0; font-size: 0.82rem; } .wayfarer-author-toolbar button { margin-right: 4px; margin-bottom: 2px; padding: 5px 8px; } #wayfarer-tick-message { min-height: 1.2em; } .wayfarer-author-tabs { flex: 0 0 auto; display: flex; gap: 2px; padding: 8px 10px 0; background: #ffffff; border-bottom: 1px solid #d8dbe2; } .wayfarer-author-tab { appearance: none; border: 0; border-bottom: 2px solid transparent; padding: 8px 10px 7px; background: transparent; color: #667085; cursor: pointer; font: inherit; font-size: 0.86rem; font-weight: 600; } .wayfarer-author-tab:hover { color: #20242a; } .wayfarer-author-tab.is-active { color: #2f5ea8; border-bottom-color: #2f5ea8; } .wayfarer-author-content { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 14px 16px 18px; } .wayfarer-author-tab-panel { display: none; } .wayfarer-author-tab-panel.is-active { display: block; } .wayfarer-author-tab-panel > :first-child { margin-top: 0; } .wayfarer-author-tab-panel h3 { margin-top: 0; font-size: 1rem; } .wayfarer-author-tab-panel h4 { margin-bottom: 8px; } .wayfarer-author-tab-panel table { width: 100%; border-collapse: collapse; font-size: 0.84rem; } .wayfarer-author-tab-panel th, .wayfarer-author-tab-panel td { padding: 6px 7px; text-align: left; vertical-align: top; } .wayfarer-author-tab-panel pre { max-width: 100%; overflow: auto; font-size: 0.78rem; } #wayfarer-author-debug-panel details { margin-top: 0 !important; } #wayfarer-author-debug-panel summary { display: none; } #wayfarer-author-debug-panel details:not([open]) > *:not(summary) { display: block; } #wayfarer-author-debug-panel details > pre { display: block; max-height: none !important; margin-top: 0 !important; } .wayfarer-player-meta { margin-bottom: 14px; padding: 10px 12px; border-radius: 7px; background: #f6f7f9; color: #667085; font-size: 0.86rem; } .wayfarer-player-meta h2 { margin: 0 0 4px; color: #20242a; font-size: 0.95rem; } .wayfarer-player-meta p { margin: 0; } .wayfarer-play-shell.author-collapsed .wayfarer-play-main { grid-template-columns: minmax(0, 1fr); } .wayfarer-play-shell.author-collapsed .wayfarer-author-pane { display: none; } @media (max-width: 900px) { body.wayfarer-play-page { overflow: auto; } .wayfarer-play-shell { height: auto; min-height: 100vh; overflow: visible; } .wayfarer-play-main { display: block; overflow: visible; padding: 8px; } .wayfarer-player-pane, .wayfarer-author-pane { overflow: visible; margin-bottom: 10px; } .wayfarer-author-pane { min-height: 480px; } .wayfarer-author-content { overflow: visible; } .wayfarer-play-shell.author-collapsed .wayfarer-author-pane { display: none; } } /* ------------------------------------------------------------------ GAME START SCREEN WAYFARER_PLAYER_START_SCREEN_V36 ------------------------------------------------------------------ */ /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * The start screen no longer implies "no Author Mode." Authors may inspect * the Author panel before initialization. The existing author-collapsed class * controls the one-column layout for normal players and collapsed authors. */ .wayfarer-start-screen { width: min(860px, 100%); margin: 0 auto; } .wayfarer-start-screen-heading { margin-bottom: 24px; } .wayfarer-start-screen-heading h2 { margin: 0 0 12px; font-size: 1.65rem; } .wayfarer-start-introduction { max-width: 72ch; color: #4b5563; line-height: 1.65; } .wayfarer-start-error { margin-bottom: 18px; padding: 10px 12px; border: 1px solid #e4a7ad; border-radius: 7px; background: #fff4f5; color: #8b1e2d; } .wayfarer-start-form { display: grid; gap: 22px; } .wayfarer-start-section, .wayfarer-start-choice { margin: 0; padding: 18px; border: 1px solid #d8dbe2; border-radius: 9px; background: #ffffff; } .wayfarer-start-section h3, .wayfarer-start-choice legend { margin: 0 0 14px; color: #20242a; font-size: 1.05rem; font-weight: 700; } .wayfarer-start-choice legend { padding: 0 6px; } .wayfarer-start-names { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } .wayfarer-start-names label { display: grid; gap: 5px; font-weight: 600; } .wayfarer-start-names input { width: 100%; padding: 9px 10px; border: 1px solid #b8bdc7; border-radius: 6px; font: inherit; } .wayfarer-start-choice { display: grid; gap: 10px; } .wayfarer-start-option { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: start; padding: 12px 13px; border: 1px solid #d8dbe2; border-radius: 7px; cursor: pointer; } .wayfarer-start-option:hover { background: #f6f7f9; } .wayfarer-start-option input { margin-top: 4px; } .wayfarer-start-option-copy { display: grid; gap: 4px; } .wayfarer-start-option-copy > span { color: #667085; font-size: 0.92rem; } .wayfarer-start-actions { display: flex; justify-content: flex-end; } .wayfarer-start-button { padding: 10px 18px; border: 1px solid #244b88; border-radius: 7px; background: #2f5ea8; color: #ffffff; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-start-button:hover { background: #244b88; } @media (max-width: 700px) { .wayfarer-start-screen { width: 100%; } .wayfarer-start-section, .wayfarer-start-choice { padding: 14px; } } /* ------------------------------------------------------------------ PLAYER ACTION SCREEN WAYFARER_ACTION_SCREEN_V39 ------------------------------------------------------------------ */ .wayfarer-action-screen { margin-top: 14px; padding: 18px; border: 1px solid #d8dbe2; border-radius: 10px; background: #ffffff; box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); } .wayfarer-action-screen[hidden], .wayfarer-travel-controls[hidden] { display: none !important; } .wayfarer-action-heading { margin-bottom: 14px; } .wayfarer-action-heading h2 { margin: 0 0 4px; font-size: 1.25rem; } .wayfarer-action-heading p { margin: 0; color: #667085; } .wayfarer-action-list { display: grid; gap: 10px; } .wayfarer-action-button { width: 100%; padding: 12px 14px; border: 1px solid #214b86; border-radius: 8px; background: #245a9b; color: #ffffff; cursor: pointer; font: inherit; font-weight: 700; text-align: left; } .wayfarer-action-button:hover { background: #1e4c84; } .wayfarer-action-button:disabled { cursor: default; opacity: 0.55; } /* ------------------------------------------------------------------ IN-CARD ACTION UTILITY VIEWS WAYFARER_ACTION_UTILITY_VIEWS_V48 ------------------------------------------------------------------ */ .wayfarer-action-utility-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 2px; } .wayfarer-action-utility-button, .wayfarer-action-back-button { padding: 10px 12px; border: 1px solid #b8bdc7; border-radius: 7px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 650; } .wayfarer-action-utility-button { width: 100%; text-align: center; } .wayfarer-action-utility-button:hover, .wayfarer-action-back-button:hover { border-color: #98a2b3; background: #f6f7f9; } .wayfarer-action-subview[hidden], .wayfarer-action-heading[hidden], .wayfarer-action-list[hidden] { display: none !important; } .wayfarer-action-subview-heading { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 18px; } .wayfarer-action-subview-heading h2 { margin: 0; font-size: 1.25rem; } .wayfarer-action-back-button { flex: 0 0 auto; } .wayfarer-action-subview-body { min-height: 90px; } /* ------------------------------------------------------------------ ENTITY INVENTORY / STATUS PRESENTATION WAYFARER_ENTITY_PRESENTATION_V49 ------------------------------------------------------------------ */ .wayfarer-inventory-list, .wayfarer-party-list { display: grid; gap: 10px; } .wayfarer-inventory-item, .wayfarer-party-member, .wayfarer-status-section { padding: 13px 14px; border: 1px solid #d8dbe2; border-radius: 8px; background: #f8fafc; } .wayfarer-inventory-item-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; } .wayfarer-inventory-quantity { flex: 0 0 auto; color: #667085; font-size: 0.9rem; font-weight: 650; } .wayfarer-status-section + .wayfarer-status-section { margin-top: 12px; } .wayfarer-status-section > h3 { margin: 0 0 10px; font-size: 1rem; } .wayfarer-status-player-name { margin: 0; font-weight: 700; } .wayfarer-party-member { background: #ffffff; } .wayfarer-party-member h4 { margin: 0; font-size: 0.98rem; } /* WAYFARER_PLAYER_TRAITS_STATUS_V52 */ .wayfarer-player-member h4::after { content: "Player"; margin-left: 8px; color: #667085; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; } .wayfarer-status-values { margin: 10px 0 0; } .wayfarer-status-values > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; padding: 6px 0; border-top: 1px solid #e3e6eb; } .wayfarer-status-values dt { color: #4b5563; } .wayfarer-status-values dd { margin: 0; font-weight: 700; text-align: right; } .wayfarer-status-muted { margin: 8px 0 0; color: #667085; font-size: 0.88rem; } /* ------------------------------------------------------------------ ENTITY DETAIL VIEW WAYFARER_ENTITY_DETAIL_VIEW_V50 ------------------------------------------------------------------ */ .wayfarer-entity-open-button { appearance: none; border: 0; padding: 0; background: transparent; color: #2f5ea8; cursor: pointer; font: inherit; font-weight: 750; text-align: left; } .wayfarer-entity-open-button:hover { text-decoration: underline; } .wayfarer-entity-open-button:focus-visible { outline: 2px solid #2f5ea8; outline-offset: 3px; border-radius: 2px; } .wayfarer-status-player-names { display: grid; gap: 4px; } .wayfarer-status-player-names .wayfarer-status-player-name { margin: 0; } .wayfarer-entity-detail-eyebrow { margin: 0 0 3px; color: #667085; font-size: 0.78rem; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; } .wayfarer-entity-detail-layout { display: grid; grid-template-columns: minmax(180px, 280px) minmax(0, 1fr); gap: 22px; align-items: start; } .wayfarer-entity-detail-layout:not(:has(.wayfarer-entity-detail-media)) { grid-template-columns: minmax(0, 1fr); } .wayfarer-entity-detail-media { margin: 0; } .wayfarer-entity-detail-media img { display: block; width: 100%; max-height: 340px; object-fit: contain; border-radius: 8px; background: #f6f7f9; } .wayfarer-entity-detail-description { margin: 0 0 16px; line-height: 1.6; } .wayfarer-entity-detail-copy > :first-child { margin-top: 0; } @media (max-width: 700px) { .wayfarer-entity-detail-layout { grid-template-columns: 1fr; } .wayfarer-entity-detail-media img { max-height: 280px; } } .wayfarer-action-empty { margin: 0; color: #667085; font-size: 0.92rem; } .wayfarer-travel-controls { margin-top: 14px; padding: 12px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid #d8dbe2; border-radius: 10px; background: #ffffff; } .wayfarer-travel-status { display: flex; align-items: center; gap: 9px; font-weight: 700; } .wayfarer-travel-indicator { width: 9px; height: 9px; border-radius: 999px; background: currentColor; } .wayfarer-travel-pause { padding: 8px 12px; border: 1px solid #aab4c3; border-radius: 7px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 600; } .wayfarer-travel-pause:hover { background: #f6f7f9; } @media (max-width: 620px) { .wayfarer-action-screen { padding: 14px; } .wayfarer-travel-controls { align-items: stretch; flex-direction: column; } .wayfarer-travel-pause { width: 100%; } .wayfarer-action-utility-row { grid-template-columns: 1fr; } } /* ------------------------------------------------------------------ LOCATION / STORY PRESENTATION WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 ------------------------------------------------------------------ */ /* * Scene prose now belongs to Presentation Mode rather than the normal Action * page. The server-rendered scene remains in the DOM temporarily for runtime * compatibility but is not player-facing. */ #wayfarer-player-scene { display: none; } .wayfarer-action-parent-context { margin-bottom: 4px !important; font-size: 0.85rem; font-weight: 600; } .wayfarer-presentation-screen { position: fixed; z-index: 10000; inset: 0; overflow-y: auto; padding: clamp(24px, 6vh, 72px) 24px; background: #15181d; color: #f8fafc; } .wayfarer-presentation-screen[hidden] { display: none !important; } .wayfarer-presentation-inner { width: min(1040px, 100%); min-height: calc(100vh - clamp(48px, 12vh, 144px)); margin: 0 auto; display: flex; flex-direction: column; justify-content: center; } .wayfarer-presentation-media { width: 100%; margin: 0 0 26px; overflow: hidden; border-radius: 12px; background: #0d0f12; } .wayfarer-presentation-media img { display: block; width: 100%; max-height: 52vh; object-fit: cover; } .wayfarer-presentation-copy { width: min(760px, 100%); margin: 0 auto; } .wayfarer-presentation-eyebrow { margin: 0 0 8px; color: #b8c0cc; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .wayfarer-presentation-copy h1 { margin: 0 0 18px; color: inherit; font-size: clamp(2rem, 5vw, 3.8rem); line-height: 1.05; } .wayfarer-presentation-description { font-size: clamp(1rem, 2vw, 1.22rem); line-height: 1.7; } .wayfarer-presentation-actions { width: min(760px, 100%); margin: 32px auto 0; display: flex; justify-content: flex-end; } .wayfarer-presentation-continue { min-width: 150px; padding: 11px 18px; border: 1px solid #f8fafc; border-radius: 8px; background: #f8fafc; color: #15181d; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-presentation-continue:hover { background: #e8edf3; } .wayfarer-presentation-continue:focus-visible { outline: 3px solid #ffffff; outline-offset: 4px; } .wayfarer-presentation-key-hint { margin: 10px 0 0; color: #9ea8b6; font-size: 0.78rem; text-align: right; } body.wayfarer-runtime-presentation { overflow: hidden; } @media (max-width: 620px) { .wayfarer-presentation-screen { padding: 20px 14px; } .wayfarer-presentation-inner { min-height: calc(100vh - 40px); } .wayfarer-presentation-actions { justify-content: stretch; } .wayfarer-presentation-continue { width: 100%; } } /* ------------------------------------------------------------------ COMBINED AUTHOR STATE + REGION WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 ------------------------------------------------------------------ */ .wayfarer-author-state-region { margin-top: 20px; padding-top: 14px; border-top: 1px solid #e4e7ec; } .wayfarer-author-state-region > :first-child { margin-top: 0; } /* ------------------------------------------------------------------ TERMINAL GAME ENDING WAYFARER_GAME_ENDING_SCREEN_V45 ------------------------------------------------------------------ */ .wayfarer-ending-screen { position: fixed; z-index: 11000; inset: 0; overflow-y: auto; padding: clamp(24px, 6vh, 72px) 24px; background: #11151a; color: #f8fafc; } .wayfarer-ending-inner { width: min(1040px, 100%); min-height: calc(100vh - clamp(48px, 12vh, 144px)); margin: 0 auto; display: flex; flex-direction: column; justify-content: center; } .wayfarer-ending-media { width: 100%; margin: 0 0 28px; overflow: hidden; border-radius: 12px; background: #080a0d; } .wayfarer-ending-media img { display: block; width: 100%; max-height: 52vh; object-fit: cover; } .wayfarer-ending-copy { width: min(760px, 100%); margin: 0 auto; } .wayfarer-ending-eyebrow { margin: 0 0 8px; color: #aeb8c5; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .wayfarer-ending-copy h1 { margin: 0 0 18px; color: inherit; font-size: clamp(2rem, 5vw, 3.8rem); line-height: 1.05; } .wayfarer-ending-text { font-size: clamp(1rem, 2vw, 1.22rem); line-height: 1.7; } body.wayfarer-game-ended { overflow: hidden; } ================================================================================ FILE: update-backups/v59-20260925-211316/play/assets/play.js ================================================================================ /* |-------------------------------------------------------------------------- | PLAYER RUNTIME CONTROLLER | WAYFARER_ACTION_SCREEN_V39 | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | Runtime modes currently exposed to the player: | | presentation -> Action -> running navigation -> presentation/Action | | Presentation is persisted by PHP. Automatic tick advancement cannot continue | while a presentation is active. | */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; if (!config) { return; } const body = document.body; const tickValue = document.getElementById( 'wayfarer-tick-value' ); const statusValue = document.getElementById( 'wayfarer-tick-status' ); const message = document.getElementById( 'wayfarer-tick-message' ); const onceButton = document.getElementById( 'wayfarer-tick-once' ); const autoButton = document.getElementById( 'wayfarer-tick-auto' ); const authorPauseButton = document.getElementById( 'wayfarer-tick-pause' ); const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); const continueTravelButton = document.querySelector( '[data-wayfarer-continue-travel]' ); const playerPauseButton = document.querySelector( '[data-wayfarer-pause-travel]' ); /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Action Mode owns one physical card. Inventory, Status, and future * utility/context views temporarily replace the normal action heading/list * inside that card without becoming persisted runtime modes. */ const actionHeading = actionScreen ? actionScreen.querySelector( '.wayfarer-action-heading' ) : null; const actionList = actionScreen ? actionScreen.querySelector( '[data-wayfarer-action-list]' ) : null; const actionSubviewPanels = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ) : []; const actionViewOpeners = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-open-action-view]' ) ) : []; const actionViewClosers = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-close-action-view]' ) ) : []; /* * Persisted narrative presentation. */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); const presentationContinueButton = presentationScreen ? presentationScreen.querySelector( '[data-wayfarer-presentation-continue]' ) : null; let running = false; let requestInFlight = false; let presentationRequestInFlight = false; let timeoutId = null; let reloadWhenIdle = false; let presentationActive = Boolean( presentationScreen && !presentationScreen.hidden ); function setMessage( text, isError = false ) { if (!message) { return; } message.textContent = text; message.style.color = isError ? 'red' : ''; } function emitModeEvent() { let mode = 'paused'; if (presentationActive) { mode = 'presentation'; } else if (running) { mode = 'running'; } window.dispatchEvent( new CustomEvent( 'wayfarer:runtime-mode', { detail: { mode: mode } } ) ); /* * Keep the v39 event names available for existing development hooks. */ window.dispatchEvent( new CustomEvent( running ? 'wayfarer:runtime-running' : 'wayfarer:runtime-paused', { detail: { mode: mode } } ) ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Generic in-card view switcher. Future Map / Shop / other views can use * the same data-wayfarer-open-action-view + data-wayfarer-action-view * contract without adding another page-level layout system. */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Resolve available views at the moment navigation occurs instead of * relying on the V48 page-load snapshot. This supports nested Entity * detail screens and future context views added to the same Action card. */ function setActionView(viewName = 'actions') { if (!actionScreen) { return; } const requested = String( viewName || 'actions' ); const showActions = requested === 'actions'; const panels = Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ); let matched = showActions; panels.forEach( function (panel) { const active = panel.dataset .wayfarerActionView === requested; panel.hidden = !active; if (active) { matched = true; } } ); /* * Fail safely if a malformed/removed view name is requested. */ if (!matched) { panels.forEach( function (panel) { panel.hidden = true; } ); if (actionHeading) { actionHeading.hidden = false; } if (actionList) { actionList.hidden = false; } return; } if (actionHeading) { actionHeading.hidden = !showActions; } if (actionList) { actionList.hidden = !showActions; } } function renderControls() { if (statusValue) { statusValue.textContent = presentationActive ? 'Presentation' : ( running ? 'Running' : 'Paused' ); } if (onceButton) { onceButton.disabled = running || requestInFlight || presentationActive; } if (autoButton) { autoButton.disabled = running || requestInFlight || presentationActive; } if (authorPauseButton) { authorPauseButton.disabled = !running; } if (continueTravelButton) { continueTravelButton.disabled = running || requestInFlight || presentationActive; } if (playerPauseButton) { playerPauseButton.disabled = !running; } if (actionScreen) { actionScreen.hidden = running || presentationActive; } if (travelControls) { travelControls.hidden = !running || presentationActive; } if (presentationScreen) { presentationScreen.hidden = !presentationActive; presentationScreen.setAttribute( 'aria-hidden', presentationActive ? 'false' : 'true' ); } body.classList.toggle( 'wayfarer-runtime-running', running ); body.classList.toggle( 'wayfarer-runtime-paused', !running && !presentationActive ); body.classList.toggle( 'wayfarer-runtime-presentation', presentationActive ); } /* * WAYFARER_LIVE_ACTION_CONTEXT_V46_1 * * Action Mode must reflect the current runtime position, not the page's * original server-rendered context. */ function renderPlayerContext(context) { if ( !context || typeof context !== 'object' ) { return; } const heading = document.querySelector( '[data-wayfarer-action-context-name]' ); const parent = document.querySelector( '[data-wayfarer-action-parent-context]' ); if (heading) { const name = String( context.context_name || 'Actions' ).trim(); heading.textContent = name || 'Actions'; } if (parent) { const parentText = String( context.parent_context || '' ).trim(); parent.textContent = parentText; parent.hidden = parentText === ''; } } function displayRuntimeValue(value) { if (typeof value === 'boolean') { return value ? 'True' : 'False'; } if ( value === null || value === undefined ) { return 'null'; } if (typeof value === 'object') { return JSON.stringify( value ); } return String( value ); } function renderRuntimeState(state) { if ( !state || typeof state !== 'object' ) { return; } if ( state.navigation && state.navigation.position !== undefined ) { const position = String( state.navigation.position ); const authorPosition = document.getElementById( 'wayfarer-author-position' ); if (authorPosition) { authorPosition.textContent = position; } } const gameTraits = state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : {}; document .querySelectorAll( '[data-wayfarer-game-trait-value]' ) .forEach( function (cell) { const traitId = cell.dataset .wayfarerGameTraitValue; if ( Object.prototype .hasOwnProperty .call( gameTraits, traitId ) ) { cell.textContent = displayRuntimeValue( gameTraits[traitId] ); } } ); document .querySelectorAll( '[data-wayfarer-region-trait-value]' ) .forEach( function (cell) { const regionId = cell.dataset .wayfarerRegionId; const traitId = cell.dataset .wayfarerRegionTraitValue; const traits = state.regions && state.regions[regionId] && state.regions[regionId].traits && typeof state.regions[regionId].traits === 'object' ? state.regions[regionId].traits : null; if ( traits && Object.prototype .hasOwnProperty .call( traits, traitId ) ) { cell.textContent = displayRuntimeValue( traits[traitId] ); } } ); const debug = document.getElementById( 'wayfarer-runtime-debug' ); if (debug) { debug.textContent = JSON.stringify( state, null, 2 ); } } async function requestTick() { if ( requestInFlight || presentationActive ) { return null; } requestInFlight = true; renderControls(); try { const response = await fetch( config.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Tick endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Tick request failed.' ); } if (tickValue) { tickValue.textContent = String( data.tick ); } renderRuntimeState( data.state ); renderPlayerContext( data.context ); setMessage( 'Tick ' + data.tick + ' saved.' ); if (data.pause === true) { /* * Suppress Action Mode during the brief interval before reload. * The server-rendered presentation will exist after reload. */ presentationActive = true; pauseRuntime( data.pause_reason || 'Travel interrupted.', true ); } return data; } catch (error) { pauseRuntime( error instanceof Error ? error.message : 'Tick request failed.' ); setMessage( error instanceof Error ? error.message : 'Tick request failed.', true ); return null; } finally { requestInFlight = false; renderControls(); if (reloadWhenIdle) { reloadWhenIdle = false; window.location.reload(); } } } async function runAutomaticTick() { if ( !running || presentationActive ) { return; } const result = await requestTick(); if ( !running || presentationActive || result === null ) { return; } timeoutId = window.setTimeout( runAutomaticTick, Number( config.intervalMs ) || 1500 ); } function startRuntime() { if ( running || requestInFlight || presentationActive ) { return; } /* * Travel always leaves inspection/context views and returns the card to * its normal Action view for the next pause. */ setActionView( 'actions' ); running = true; setMessage( 'Automatic advancement started.' ); renderControls(); emitModeEvent(); runAutomaticTick(); } function pauseRuntime( reason = 'Automatic advancement paused.', reloadForPresentation = false ) { const wasRunning = running; running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } setMessage( reason ); if (wasRunning) { setActionView( 'actions' ); } renderControls(); if (wasRunning) { emitModeEvent(); } if (reloadForPresentation) { if (requestInFlight) { reloadWhenIdle = true; } else { window.location.reload(); } } } async function dismissPresentation() { if ( !presentationActive || !presentationScreen || presentationRequestInFlight ) { return; } presentationRequestInFlight = true; if (presentationContinueButton) { presentationContinueButton.disabled = true; } const presentationType = presentationScreen.dataset .wayfarerPresentationType || ''; const presentationId = Number( presentationScreen.dataset .wayfarerPresentationId || 0 ); try { const response = await fetch( config.presentationEndpoint || 'api/presentation.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, type: presentationType, id: presentationId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Presentation endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not continue.' ); } presentationActive = false; setActionView( 'actions' ); renderRuntimeState( data.state ); renderPlayerContext( data.context ); renderControls(); emitModeEvent(); /* * Place keyboard focus on the first available Action so the * transition works cleanly for keyboard players too. */ const firstAction = actionScreen ? actionScreen.querySelector( 'button:not([disabled]), a[href]' ) : null; if ( firstAction && typeof firstAction.focus === 'function' ) { firstAction.focus(); } } catch (error) { if (presentationContinueButton) { presentationContinueButton.disabled = false; } window.alert( error instanceof Error ? error.message : 'Could not continue.' ); } finally { presentationRequestInFlight = false; if ( presentationContinueButton && presentationActive ) { presentationContinueButton.disabled = false; } } } if (onceButton) { onceButton.addEventListener( 'click', async function () { if ( running || presentationActive ) { return; } setMessage(''); await requestTick(); } ); } if (autoButton) { autoButton.addEventListener( 'click', startRuntime ); } if (authorPauseButton) { authorPauseButton.addEventListener( 'click', function () { pauseRuntime( 'Automatic advancement paused.' ); } ); } if (continueTravelButton) { continueTravelButton.addEventListener( 'click', startRuntime ); } if (playerPauseButton) { playerPauseButton.addEventListener( 'click', function () { /* * WAYFARER_SMOOTH_MANUAL_PAUSE_V46_2 * * v46.1 keeps Action context current in-place, so manual Pause * no longer needs a full page reload. */ pauseRuntime( 'Travel paused.' ); } ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Ensure V50 detail panels physically belong to the Action card. This is * harmless when the PHP view already placed them correctly, and repairs * any earlier V50 markup that landed just outside the host. */ if (actionScreen) { document .querySelectorAll( '.wayfarer-entity-detail-view' ) .forEach( function (panel) { if ( !actionScreen.contains( panel ) ) { actionScreen.appendChild( panel ); } } ); /* * Delegate navigation from the Action card itself. Any current or * future control using the generic data attributes automatically * participates in the same router. */ actionScreen.addEventListener( 'click', function (event) { const opener = event.target.closest( '[data-wayfarer-open-action-view]' ); if ( opener && actionScreen.contains( opener ) ) { if ( running || presentationActive ) { return; } event.preventDefault(); setActionView( opener.dataset .wayfarerOpenActionView || 'actions' ); return; } const closer = event.target.closest( '[data-wayfarer-close-action-view]' ); if ( !closer || !actionScreen.contains( closer ) ) { return; } event.preventDefault(); const returnView = closer.dataset .wayfarerReturnActionView || 'actions'; setActionView( returnView ); let focusTarget = null; if (returnView === 'actions') { focusTarget = actionList ? actionList.querySelector( 'button:not([disabled]), a[href]' ) : null; } else { const parentPanel = actionScreen.querySelector( '[data-wayfarer-action-view="' + returnView + '"]' ); focusTarget = parentPanel ? parentPanel.querySelector( '[data-wayfarer-open-action-view], button:not([disabled]), a[href]' ) : null; } if ( focusTarget && typeof focusTarget.focus === 'function' ) { focusTarget.focus(); } } ); } if (presentationContinueButton) { presentationContinueButton.addEventListener( 'click', dismissPresentation ); } document.addEventListener( 'keydown', function (event) { if ( !presentationActive || presentationRequestInFlight ) { return; } const target = event.target; if ( target && target.matches && target.matches( 'input, textarea, select, button, a[href], [contenteditable="true"]' ) ) { return; } if ( event.key !== 'Enter' && event.key !== ' ' ) { return; } event.preventDefault(); dismissPresentation(); } ); window.addEventListener( 'pagehide', function () { running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } } ); window.wayfarerRuntimeController = { start: startRuntime, pause: pauseRuntime, dismissPresentation: dismissPresentation, isRunning: function () { return running; }, mode: function () { if (presentationActive) { return 'presentation'; } return running ? 'running' : 'paused'; } }; setActionView( 'actions' ); renderControls(); emitModeEvent(); })(); /* |-------------------------------------------------------------------------- | WAYFARER PLAY SHELL v20 |-------------------------------------------------------------------------- | | Reorganizes existing server-rendered nodes into the viewport shell. | Existing nodes are MOVED, not cloned/replaced, so the tick-control | event listeners installed above remain intact. | */ (() => { 'use strict'; if ( document.getElementById( 'wayfarer-play-shell' ) ) { return; } function exactHeading( level, text ) { const headings = document.querySelectorAll( level ); for (const heading of headings) { if ( heading.textContent .replace(/\s+/g, ' ') .trim() === text ) { return heading; } } return null; } function moveRange( start, stopExclusive, destination ) { if ( !start || !destination ) { return; } let node = start; while ( node && node !== stopExclusive ) { const next = node.nextSibling; destination.appendChild( node ); node = next; } } const body = document.body; /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * PHP decides whether Author Mode is permitted. JavaScript only shapes the * interface around that server-side decision. */ const canUseAuthorMode = body.dataset.wayfarerAuthorMode === '1'; body.classList.add( 'wayfarer-play-page' ); const shell = document.createElement( 'div' ); shell.id = 'wayfarer-play-shell'; shell.className = 'wayfarer-play-shell'; const header = document.createElement( 'header' ); header.className = 'wayfarer-play-shell-header'; const title = document.createElement( 'div' ); title.className = 'wayfarer-play-title'; const actions = document.createElement( 'div' ); actions.className = 'wayfarer-shell-actions'; const toggleAuthor = document.createElement( 'button' ); toggleAuthor.type = 'button'; toggleAuthor.className = 'wayfarer-shell-button'; if (canUseAuthorMode) { actions.appendChild( toggleAuthor ); } header.appendChild( title ); if (canUseAuthorMode) { header.appendChild( actions ); } const main = document.createElement( 'main' ); main.className = 'wayfarer-play-main'; const playerPane = document.createElement( 'section' ); playerPane.className = 'wayfarer-player-pane'; const authorPane = document.createElement( 'aside' ); authorPane.className = 'wayfarer-author-pane'; const authorHeader = document.createElement( 'div' ); authorHeader.className = 'wayfarer-author-header'; const authorToolbar = document.createElement( 'div' ); authorToolbar.className = 'wayfarer-author-toolbar'; const tabBar = document.createElement( 'div' ); tabBar.className = 'wayfarer-author-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const authorContent = document.createElement( 'div' ); authorContent.className = 'wayfarer-author-content'; authorPane.appendChild( authorHeader ); authorPane.appendChild( authorToolbar ); authorPane.appendChild( tabBar ); authorPane.appendChild( authorContent ); main.appendChild( playerPane ); if (canUseAuthorMode) { main.appendChild( authorPane ); } else { /* * Keep the player workspace full-width when Author Mode is unavailable. * No Author pane was rendered by PHP and none is attached here. */ shell.classList.add( 'author-collapsed' ); } shell.appendChild( header ); shell.appendChild( main ); body.insertBefore( shell, body.firstChild ); // ------------------------------------------------------------ // Top title/player identity // ------------------------------------------------------------ const pageTitle = Array.from( body.querySelectorAll( ':scope > h1' ) )[0]; if (pageTitle) { const playerIdentity = pageTitle.nextElementSibling; title.appendChild( pageTitle ); if ( playerIdentity && playerIdentity.tagName === 'P' ) { title.appendChild( playerIdentity ); } } // ------------------------------------------------------------ // Player content // ------------------------------------------------------------ /* * WAYFARER_PLAYER_START_SCREEN_V36 * * Before runtime state exists, the start screen owns the full player * workspace and Author Mode is intentionally hidden. */ const startScreen = document.querySelector( '[data-wayfarer-start-screen]' ); if (startScreen) { shell.classList.add( 'is-starting' ); playerPane.appendChild( startScreen ); } const scene = document.getElementById( 'wayfarer-player-scene' ); const gameStateHeading = exactHeading( 'h2', 'Game State' ) || exactHeading( 'h2', 'Game Not Initialized' ); if (gameStateHeading) { const meta = document.createElement( 'div' ); meta.className = 'wayfarer-player-meta'; /* * Move from Game State through the node immediately before the * player scene. This includes initialization messaging/forms but * stops before the actual scene. */ moveRange( gameStateHeading, scene, meta ); playerPane.appendChild( meta ); } if (scene) { playerPane.appendChild( scene ); } /* * WAYFARER_ACTION_SCREEN_V39 * * Action Mode is player-facing and exists independently of Author Mode. */ const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); if (actionScreen) { playerPane.appendChild( actionScreen ); } /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); if (presentationScreen) { playerPane.appendChild( presentationScreen ); } const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); if (travelControls) { playerPane.appendChild( travelControls ); } // ------------------------------------------------------------ // Author header // ------------------------------------------------------------ const authorHeading = exactHeading( 'h2', 'Author Mode' ); if (authorHeading) { const builderLinkBlock = authorHeading.nextElementSibling; authorHeader.appendChild( authorHeading ); if ( builderLinkBlock && builderLinkBlock.tagName === 'P' ) { authorHeader.appendChild( builderLinkBlock ); } } // ------------------------------------------------------------ // Runtime controls // ------------------------------------------------------------ const tickPanel = document.getElementById( 'wayfarer-tick-test' ); if (tickPanel) { authorToolbar.appendChild( tickPanel ); } // ------------------------------------------------------------ // Author tabs // ------------------------------------------------------------ /* WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 */ const panelDefinitions = [ { key: 'state', label: 'State' }, { key: 'debug', label: 'Debug' } ]; function activateTab(key) { document .querySelectorAll( '.wayfarer-author-tab' ) .forEach( function (button) { const active = button.dataset .wayfarerAuthorTab === key; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); authorContent .querySelectorAll( '.wayfarer-author-tab-panel' ) .forEach( function (panel) { panel.classList.toggle( 'is-active', panel.dataset .wayfarerAuthorPanel === key ); } ); try { window.localStorage.setItem( 'wayfarerAuthorTab', key ); } catch (error) { // Storage is optional. } } for (const definition of panelDefinitions) { const panel = document.querySelector( '[data-wayfarer-author-panel="' + definition.key + '"]' ); if (!panel) { continue; } authorContent.appendChild( panel ); const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-author-tab'; button.dataset.wayfarerAuthorTab = definition.key; button.setAttribute( 'role', 'tab' ); button.textContent = definition.label; button.addEventListener( 'click', function () { activateTab( definition.key ); } ); tabBar.appendChild( button ); } let initialTab = 'state'; try { let savedTab = window.localStorage.getItem( 'wayfarerAuthorTab' ); if ( savedTab === 'region' ) { savedTab = 'state'; window.localStorage.setItem( 'wayfarerAuthorTab', 'state' ); } if ( savedTab && authorContent.querySelector( '[data-wayfarer-author-panel="' + savedTab + '"]' ) ) { initialTab = savedTab; } } catch (error) { // Storage is optional. } activateTab( initialTab ); // ------------------------------------------------------------ // Hide / Show Author Mode // WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 // ------------------------------------------------------------ if (canUseAuthorMode) { function renderAuthorToggle() { const collapsed = shell.classList.contains( 'author-collapsed' ); toggleAuthor.textContent = collapsed ? 'Show Author' : 'Hide Author'; toggleAuthor.setAttribute( 'aria-expanded', collapsed ? 'false' : 'true' ); } toggleAuthor.addEventListener( 'click', function () { shell.classList.toggle( 'author-collapsed' ); try { window.localStorage.setItem( 'wayfarerAuthorCollapsed', shell.classList.contains( 'author-collapsed' ) ? '1' : '0' ); } catch (error) { // Storage is optional. } renderAuthorToggle(); } ); try { if ( window.localStorage.getItem( 'wayfarerAuthorCollapsed' ) === '1' ) { shell.classList.add( 'author-collapsed' ); } } catch (error) { // Storage is optional. } renderAuthorToggle(); } else { /* * Player role is not merely a hidden Author interface. Keep the shell * permanently in player-only layout. */ shell.classList.add( 'author-collapsed' ); } // ------------------------------------------------------------ // Remove otherwise-empty top-level HRs left by the old document flow. // ------------------------------------------------------------ body .querySelectorAll( ':scope > hr' ) .forEach( function (rule) { rule.remove(); } ); })(); ================================================================================ FILE: update-backups/v59-20260925-211316/play/includes/action-screen.php ================================================================================ 0) { $actionRegionStmt = $pdo->prepare( " SELECT id, name, navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $actionRegionStmt->execute([ $actionRegionId, (int)$gameId, ]); $actionRegion = $actionRegionStmt->fetch( PDO::FETCH_ASSOC ); if ($actionRegion) { $actionRegionNavigationType = (string)( $actionRegion['navigation_type'] ?? '' ); $actionContextName = trim( (string)( $actionRegion['name'] ?? '' ) ) ?: 'Actions'; } } $actionLocation = wayfarerRuntimeFindCurrentLocation( $pdo, (int)$gameId, $state ); if ($actionLocation) { $locationName = trim( (string)( $actionLocation['name'] ?? '' ) ); if ($locationName !== '') { $actionContextName = $locationName; } } $actionCanTravel = strtolower( trim( $actionRegionNavigationType ) ) === 'linear'; /* * WAYFARER_ENTITY_PRESENTATION_V49 * * The V48 card switcher remains generic. V49 supplies the Inventory / Status * view models from canonical runtime Entity state. */ $actionEntityPresentation = wayfarerRuntimeBuildEntityPresentation( $pdo, (int)$gameId, $state, is_array($gameConfig ?? null) ? $gameConfig : [] ); $actionInventory = $actionEntityPresentation['inventory'] ?? []; $actionParty = $actionEntityPresentation['party'] ?? []; /* WAYFARER_PLAYER_TRAITS_STATUS_V52 */ $actionPlayerPresentation = wayfarerRuntimeBuildPlayerPresentation( is_array($gameConfig ?? null) ? $gameConfig : [], $state ); $actionPlayers = $actionPlayerPresentation['players'] ?? []; $actionGameStatusTraits = $actionPlayerPresentation['game_status_traits'] ?? []; ?>

>

What would you like to do?

No actions are currently available.

================================================================================ FILE: update-backups/v59-20260925-211316/play/includes/presentation-screen.php ================================================================================ prepare( " SELECT l.*, r.game_id, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE l.id = ? AND r.game_id = ? LIMIT 1 " ); $stmt->execute([ $presentationId, (int)$gameId, ]); $presentationRecord = $stmt->fetch( PDO::FETCH_ASSOC ); if ($presentationRecord) { /* * Media links use the authored table name as object_type. */ $mediaStmt = $pdo->prepare( " SELECT m.id, m.name, m.relative_path, m.mime_type FROM wf_media_links ml INNER JOIN wf_media m ON m.id = ml.media_id WHERE ml.object_type = 'wf_locations' AND ml.object_id = ? AND ml.slot = 'image' AND m.game_id = ? AND m.media_type = 'image' LIMIT 1 " ); $mediaStmt->execute([ $presentationId, (int)$gameId, ]); $presentationImage = $mediaStmt->fetch( PDO::FETCH_ASSOC ); } } if (!$presentationRecord) { /* * Keep the player from becoming trapped if an authored object is removed * after its presentation state was persisted. */ $presentationRecord = [ 'name' => 'Continue', 'description' => 'This presentation is no longer available.', ]; } $presentationName = trim( (string)( $presentationRecord['name'] ?? '' ) ); if ($presentationName === '') { $presentationName = 'Location'; } $presentationDescription = trim( (string)( $presentationRecord['description'] ?? '' ) ); $presentationImageUrl = ''; if ( is_array($presentationImage) && !empty( $presentationImage['relative_path'] ) ) { $relativePath = ltrim( str_replace( '\\', '/', (string)$presentationImage[ 'relative_path' ] ), '/' ); /* * Media uploads are stored beneath the Wayfarer root. play/ is one level * below that root. */ if ( $relativePath !== '' && strpos( $relativePath, '..' ) === false ) { $presentationImageUrl = '../' . $relativePath; } } ?> ================================================================================ FILE: update-backups/v59-20260925-211316/play/includes/runtime-bootstrap.php ================================================================================ ================================================================================ FILE: update-backups/v59-20260925-211316/play/includes/runtime.php ================================================================================ prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? ORDER BY id " ); $stmt->execute([ $gameId ]); $regions = $stmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($regions as $region) { $regionId = (int)( $region['id'] ?? 0 ); if ($regionId <= 0) { continue; } $regionKey = (string)$regionId; $config = wayfarerRuntimeDecodeConfig( $region['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset( $runtimeState['regions'][$regionKey] ) || !is_array( $runtimeState['regions'][$regionKey] ) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset( $runtimeState['regions'][$regionKey]['traits'] ) || !is_array( $runtimeState['regions'][$regionKey]['traits'] ) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['regions'][$regionKey]['traits'] ) ) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $initialValue; } } } /* |-------------------------------------------------------------------------- | Persisted state for every Location | WAYFARER_ALL_LOCATION_STATE_V56 |-------------------------------------------------------------------------- | | Locations can own Traits, so universal Conditions/Effects need persistent | per-playthrough Location Trait state just like Regions. | */ function wayfarerRuntimeEnsureAllLocationTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['locations']) || !is_array($runtimeState['locations']) ) { $runtimeState['locations'] = []; } $stmt = $pdo->prepare( " SELECT l.id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $location ) { $locationId = (int)($location['id'] ?? 0); if ($locationId <= 0) { continue; } $locationKey = (string)$locationId; $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset($runtimeState['locations'][$locationKey]) || !is_array($runtimeState['locations'][$locationKey]) ) { $runtimeState['locations'][$locationKey] = []; } if ( !isset($runtimeState['locations'][$locationKey]['traits']) || !is_array($runtimeState['locations'][$locationKey]['traits']) ) { $runtimeState['locations'][$locationKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['locations'][$locationKey]['traits'] ) ) { continue; } $runtimeState['locations'][$locationKey]['traits'][$traitId] = $initialValue; } } } function wayfarerRuntimeLoadStartingRegion( PDO $pdo, int $gameId, ?array $gameConfig = null ): ?array { if ($gameId <= 0) { return null; } if ($gameConfig === null) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { return null; } $gameConfig = wayfarerRuntimeDecodeConfig( $rawConfig ); } $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT * FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $startingRegionId, $gameId, ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); return $region ?: null; } function wayfarerRuntimeInitializeGame( PDO $pdo, int $gameUserId, array $setupInput = [] ): array { if ($gameUserId <= 0) { throw new RuntimeException( 'A valid game-user ID is required to initialize the game.' ); } try { $pdo->beginTransaction(); /* * Lock the playthrough row so two initialize requests cannot create * competing starting states. */ $stmt = $pdo->prepare( " SELECT gu.id, gu.game_id, gu.user_id, gu.role, gu.state, g.config AS game_config, g.player_slots AS player_slots FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id WHERE gu.id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } /* * Initialization is intentionally one-way. To restart a development * game, clear wf_game_users.state to NULL first. */ if ( $row['state'] !== null && $row['state'] !== '' ) { throw new RuntimeException( 'This game-user already has runtime state.' ); } $gameId = (int)$row['game_id']; $gameConfig = wayfarerRuntimeDecodeConfig( $row['game_config'] ?? null ); $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { throw new RuntimeException( 'This Game does not have a Starting Region configured.' ); } $startingRegion = wayfarerRuntimeLoadStartingRegion( $pdo, $gameId, $gameConfig ); if (!$startingRegion) { throw new RuntimeException( 'The configured Starting Region does not exist for this Game.' ); } $regionConfig = wayfarerRuntimeDecodeConfig( $startingRegion['config'] ?? null ); $gameTraits = wayfarerRuntimeInitialTraitState( $gameConfig ); $regionTraits = wayfarerRuntimeInitialTraitState( $regionConfig ); $navigation = [ 'region_id' => (int)$startingRegion['id'], ]; /* * Linear travel currently begins at coordinate zero. * Other navigation modes will get their own authored start-state * rules when those modes are implemented. */ if ( strtolower( trim( (string)( $startingRegion['navigation_type'] ?? '' ) ) ) === 'linear' ) { $navigation['position'] = 0; } $regionKey = (string)$startingRegion['id']; $state = [ /* * WAYFARER_RUNTIME_SCHEMA_V55 * Version the persisted playthrough contract explicitly. */ 'schema_version' => 1, 'game' => [ 'traits' => $gameTraits, ], 'navigation' => $navigation, 'regions' => [ $regionKey => [ 'traits' => $regionTraits, ], ], 'runtime' => [ 'tick' => 0, ], ]; /* * WAYFARER_ALL_REGION_STATE_V46 * * Global conditions can inspect Region state before that Region has * ever been visited. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $state ); /* WAYFARER_INITIALIZE_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_V49 * * Items and Characters receive canonical per-playthrough state before * any starting setup effects are applied. */ wayfarerRuntimeEnsureEntityState( $pdo, $gameId, $state ); /* * WAYFARER_APPLY_STARTING_CHOICES_V36 * * Validate all player-entered setup data before persisting anything. * Then apply the selected options' starting effects to the canonical * initial runtime state. */ $preparedSetup = wayfarerRuntimePrepareStartingSetup( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $setupInput ); /* * WAYFARER_PLAYER_TRAITS_RUNTIME_V52 * * Persist the validated player-character names before creating the * per-player Trait state. Each slot receives independent values. */ $state['setup'] = [ 'player_names' => $preparedSetup['player_names'], 'choices' => $preparedSetup['choices'], ]; wayfarerRuntimeEnsureGameTraitState( $gameConfig, $state ); wayfarerRuntimeEnsurePlayerState( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $state ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $startingEffectReports = []; foreach ( $preparedSetup['effects'] as $startingEffect ) { /* WAYFARER_STARTING_CHOICE_RANDOM_RUNTIME_V58 */ $resolvedStartingValue = wayfarerRuntimeRollEffectValue( $startingEffect ); if (!$resolvedStartingValue['valid']) { throw new RuntimeException( 'Unable to resolve a Starting Choice effect value: ' . $resolvedStartingValue['error'] ); } $concreteStartingEffect = $startingEffect; $concreteStartingEffect['value'] = $resolvedStartingValue['value']; $report = wayfarerApplyRuntimeEffect( $state, $concreteStartingEffect, (int)$startingRegion['id'], $traitDefinitions ); if ( !empty($report['error']) ) { throw new RuntimeException( 'Unable to apply a Starting Choice effect: ' . $report['error'] ); } $report['value_mode'] = $resolvedStartingValue['mode']; $report['rolled_value'] = $resolvedStartingValue['value']; if ($resolvedStartingValue['mode'] === 'random') { $report['random_min'] = $resolvedStartingValue['min']; $report['random_max'] = $resolvedStartingValue['max']; } $startingEffectReports[] = $report; } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * * Starting-effect reports are intentionally not persisted. The effects * themselves have already mutated canonical state. */ /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 * * A game that begins exactly on an active Location should present that * Location before ordinary Action Mode. */ $startingLocation = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $state ); if ($startingLocation) { /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Starting on a Location is a real Location trigger. Apply its * one-time Effects before opening Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, $gameId, $startingLocation, $state ); $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$startingLocation['id'], 'phase' => 'presentation', 'trigger' => 'initial', 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /* WAYFARER_GAME_ENDINGS_INITIAL_V45 */ wayfarerRuntimeEvaluateGameEndings( $pdo, $gameId, $state ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => $gameId, 'starting_region_id' => (int)$startingRegion['id'], 'state' => $state, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Resolve a game-user record for the tick prototype. * * Preferred: * explicit wf_game_users.id * * Temporary development fallback: * first wf_game_users record. * * The fallback exists only because Wayfarer has not yet wired the play * page to authentication / game selection. Once that exists, remove the * fallback and always pass the active game_user_id explicitly. */ function wayfarerRuntimeResolveGameUserId( PDO $pdo, ?int $requestedId = null ): int { if ($requestedId !== null && $requestedId > 0) { $stmt = $pdo->prepare( " SELECT id FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $requestedId ]); $id = $stmt->fetchColumn(); if ($id !== false) { return (int)$id; } throw new RuntimeException( 'The requested game-user record does not exist.' ); } $id = $pdo ->query( " SELECT id FROM wf_game_users ORDER BY id LIMIT 1 " ) ->fetchColumn(); if ($id === false) { throw new RuntimeException( 'No wf_game_users record exists.' ); } return (int)$id; } /** * Load one game-user runtime state. */ function wayfarerRuntimeLoad( PDO $pdo, int $gameUserId ): array { $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'state' => $state, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /** * Advance exactly one tick. * * This is intentionally the entire v14 game engine: * * state.runtime.tick += 1 * * The transaction and row lock are already in place so later work can add * movement/effects without changing the request architecture. */ /* |-------------------------------------------------------------------------- | Linear Location crossing | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | A tick may move farther than the distance to the next authored Location. | This resolver finds the FIRST active Location crossed in the direction of | travel. The movement is then clamped to that exact authored coordinate. | | Examples: | 45 -> 55, Location 50 => stop at 50 | 55 -> 45, Location 50 => stop at 50 | | The starting coordinate is intentionally exclusive so leaving a Location | does not immediately trigger that same Location again. | */ /* |-------------------------------------------------------------------------- | Location Conditions | WAYFARER_LOCATION_CONDITIONS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeLocationConditionsPass( PDO $pdo, int $gameId, array $location, array &$runtimeState ): bool { $config = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $conditions = $config['conditions'] ?? null; $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; return wayfarerConditionsPass( $conditions, $resolver ); } /* |-------------------------------------------------------------------------- | Game Endings | WAYFARER_GAME_ENDINGS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeEvaluateGameEndings( PDO $pdo, int $gameId, array &$runtimeState ): ?array { /* * WAYFARER_ALL_REGION_STATE_ENDINGS_V46 */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $runtimeState ); /* WAYFARER_RECOVER_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $runtimeState ); if ( isset( $runtimeState['runtime']['ending'] ) && is_array( $runtimeState['runtime']['ending'] ) ) { return $runtimeState['runtime']['ending']; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); $config = wayfarerRuntimeDecodeConfig( $raw === false ? null : $raw ); $endings = $config['endings'] ?? []; if (!is_array($endings)) { return null; } $ordered = []; foreach ( array_values($endings) as $index => $ending ) { if (!is_array($ending)) { continue; } $ending['_runtime_order'] = $index; $ordered[] = $ending; } usort( $ordered, function ( array $a, array $b ): int { $priorityCompare = (int)( $b['priority'] ?? 0 ) <=> (int)( $a['priority'] ?? 0 ); if ($priorityCompare !== 0) { return $priorityCompare; } return (int)( $a['_runtime_order'] ?? 0 ) <=> (int)( $b['_runtime_order'] ?? 0 ); } ); $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; foreach ($ordered as $ending) { if ( !wayfarerConditionsPass( $ending['conditions'] ?? null, $resolver ) ) { continue; } $terminal = [ 'id' => (string)( $ending['id'] ?? '' ), 'title' => (string)( $ending['title'] ?? 'The End' ), 'text' => (string)( $ending['text'] ?? '' ), 'image_media_id' => (int)( $ending['image_media_id'] ?? 0 ), 'priority' => (int)( $ending['priority'] ?? 0 ), 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), ]; if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['ending'] = $terminal; unset( $runtimeState['runtime']['presentation'] ); return $terminal; } return null; } function wayfarerRuntimeFindCrossedLocation( PDO $pdo, int $gameId, int $regionId, $fromPosition, $toPosition, array &$runtimeState ): ?array { /* * WAYFARER_CROSSED_LOCATION_STATE_FIX_V46_1 * * Location Conditions are evaluated against the live runtime state. */ if ( $gameId <= 0 || $regionId <= 0 || !is_numeric($fromPosition) || !is_numeric($toPosition) ) { return null; } $from = (float)$fromPosition; $to = (float)$toPosition; if ($from === $to) { return null; } /* * Crossing interception currently applies only to Linear Regions. */ $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); $forward = $to > $from; $candidate = null; $candidatePosition = null; foreach ($locations as $location) { $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $locationPosition = (float)$config['position']; /* * The starting point is exclusive; the destination is inclusive. */ $crossed = $forward ? ( $locationPosition > $from && $locationPosition <= $to ) : ( $locationPosition < $from && $locationPosition >= $to ); if (!$crossed) { continue; } $location['_runtime_config'] = $config; if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } if ($candidate === null) { $candidate = $location; $candidatePosition = $locationPosition; } else { $nearer = $forward ? ( $locationPosition < $candidatePosition ) : ( $locationPosition > $candidatePosition ); $samePositionLowerId = $locationPosition === $candidatePosition && (int)$location['id'] < (int)$candidate['id']; if ( $nearer || $samePositionLowerId ) { $candidate = $location; $candidatePosition = $locationPosition; } } } if ($candidate === null) { return null; } $candidate['_runtime_position'] = wayfarerNormalizeRuntimeNumber( (float)$candidatePosition ); $candidate['_runtime_config'] = wayfarerRuntimeDecodeConfig( $candidate['config'] ?? null ); return $candidate; } /* |-------------------------------------------------------------------------- | Player spatial context |-------------------------------------------------------------------------- | WAYFARER_PLAYER_CONTEXT_V46_1 | | This is the canonical player-facing spatial label for Action Mode: | | exact eligible Location -> Location name + Region parent context | otherwise -> Region name | | It is returned by runtime APIs so the browser does not keep displaying the | Location name from the page's original server render after movement. | */ function wayfarerRuntimePlayerContext( PDO $pdo, int $gameId, array $runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionName = ''; if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $rawRegionName = $stmt->fetchColumn(); if ($rawRegionName !== false) { $regionName = trim( (string)$rawRegionName ); } } $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); $locationId = 0; $locationName = ''; if ($location) { $locationId = (int)( $location['id'] ?? 0 ); $locationName = trim( (string)( $location['name'] ?? '' ) ); } $contextName = $locationName !== '' ? $locationName : ( $regionName !== '' ? $regionName : 'Actions' ); return [ 'region_id' => $regionId, 'region_name' => $regionName, 'location_id' => $locationId, 'location_name' => $locationName, 'context_name' => $contextName, 'parent_context' => $locationName !== '' ? $regionName : '', ]; } /* |-------------------------------------------------------------------------- | Presentation dismissal |-------------------------------------------------------------------------- | | Presentation is persisted runtime state. This function is intentionally | generic even though v40 renders only Location presentations; Storylets can | use the same mechanism later. | */ function wayfarerRuntimeDismissPresentation( PDO $pdo, int $gameUserId, string $expectedType = '', int $expectedId = 0 ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); $presentation = $state['runtime']['presentation'] ?? null; if ( !is_array($presentation) || empty($presentation['type']) || empty($presentation['id']) ) { throw new RuntimeException( 'There is no active presentation to continue.' ); } if ( $expectedType !== '' && (string)$presentation['type'] !== $expectedType ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } if ( $expectedId > 0 && (int)$presentation['id'] !== $expectedId ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } $state['runtime']['last_presentation'] = $presentation; unset( $state['runtime']['presentation'] ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'dismissed' => $presentation, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Advance exactly one tick. * * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ function wayfarerRuntimeAdvanceTick( PDO $pdo, int $gameUserId ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } /* * WAYFARER_GAME_ENDINGS_TERMINAL_V45 * * Endings are terminal runtime state. */ if ( isset( $state['runtime']['ending'] ) && is_array( $state['runtime']['ending'] ) ) { throw new RuntimeException( 'This game has ended.' ); } /* * Narrative presentation is a true runtime pause, not merely a hidden * browser control. Even Author Mode cannot advance ticks through it. */ if ( isset( $state['runtime']['presentation'] ) && is_array( $state['runtime']['presentation'] ) ) { throw new RuntimeException( 'Continue the current presentation before advancing time.' ); } /* * WAYFARER_ALL_REGION_STATE_TICK_V46 * * Recovery path for playthroughs initialized before v46, and for * Regions/Traits authored after a playthrough began. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_LOCATION_STATE_TICK_RECOVERY_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, (int)$row['game_id'], $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_TICK_V49 * * Recovery path for playthroughs created before V49 and for Entities * or Traits authored after play began. */ wayfarerRuntimeEnsureEntityState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_PLAYER_TRAITS_TICK_RECOVERY_V52 */ wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$row['game_id'], $state ); $regionIdBeforeTick = (int)( $state['navigation']['region_id'] ?? 0 ); $positionBeforeTick = $state['navigation']['position'] ?? null; $currentTick = (int)( $state['runtime']['tick'] ?? 0 ); $state['runtime']['tick'] = $currentTick + 1; /* * Global Game Tick Effects run first. */ wayfarerApplyGameTickEffects( $pdo, (int)$row['game_id'], $state ); /* * Current Region Tick Effects then perform Region-level movement and * state changes. */ wayfarerApplyCurrentRegionTickEffects( $pdo, $state ); /* * Resolve whether the completed movement would have crossed an active * Location. If so, stop at the nearest crossed Location rather than * accepting the overshot position. */ $regionIdAfterMovement = (int)( $state['navigation']['region_id'] ?? 0 ); $positionAfterMovement = $state['navigation']['position'] ?? null; $crossedLocation = null; /* * We only interpret movement as a continuous path while the player * remains in the same Region. Region transitions will later get their * own explicit navigation semantics. */ if ( $regionIdBeforeTick > 0 && $regionIdBeforeTick === $regionIdAfterMovement ) { $crossedLocation = wayfarerRuntimeFindCrossedLocation( $pdo, (int)$row['game_id'], $regionIdAfterMovement, $positionBeforeTick, $positionAfterMovement, $state ); } $arrivalPosition = null; if ($crossedLocation) { $arrivalPosition = $crossedLocation[ '_runtime_position' ]; $state['navigation']['position'] = $arrivalPosition; /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Event Effects execute once at the trigger boundary. They run * before the Location's Tick Effects and before Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, (int)$row['game_id'], $crossedLocation, $state ); } /* * WAYFARER_LOCATION_EFFECTS_ONLY_RUNTIME_V43 * * Locations no longer own Tick Effects. Game and Region Tick Effects * have already advanced the simulation; a crossed Location contributes * its one-time Effects and then interrupts with Presentation Mode. */ if ($crossedLocation) { $state['navigation']['position'] = $arrivalPosition; $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$crossedLocation['id'], 'phase' => 'presentation', 'trigger' => 'arrival', 'tick' => (int)$state['runtime']['tick'], ]; $state['runtime']['last_location_arrival'] = [ 'location_id' => (int)$crossedLocation['id'], 'region_id' => $regionIdAfterMovement, 'position' => $arrivalPosition, 'tick' => (int)$state['runtime']['tick'], ]; } /* WAYFARER_GAME_ENDINGS_AFTER_TICK_V45 */ $ending = wayfarerRuntimeEvaluateGameEndings( $pdo, (int)$row['game_id'], $state ); $state['runtime']['last_tick_at'] = gmdate('c'); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId ]); $pdo->commit(); $presentation = $state['runtime']['presentation'] ?? null; $pausedForPresentation = is_array($presentation) && !empty($presentation['type']) && !empty($presentation['id']); $ending = $state['runtime']['ending'] ?? null; $ended = is_array($ending) && !empty($ending['id']); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'tick' => (int)$state['runtime']['tick'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'pause' => $pausedForPresentation || $ended, 'pause_reason' => $ended ? 'Game ended.' : ( $pausedForPresentation ? 'Location reached.' : '' ), 'presentation' => $pausedForPresentation ? $presentation : null, 'ending' => $ended ? $ending : null, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: update-backups/v59-20260925-211316/play/index.php ================================================================================ prepare(" SELECT g.id AS game_id, g.name AS game_name, g.slug AS game_slug, g.instructions AS game_instructions, g.player_slots AS player_slots, g.config AS game_config, gu.id AS game_user_id, gu.role AS game_user_role, gu.state, u.username, u.display_name FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id INNER JOIN wf_users u ON u.id = gu.user_id WHERE gu.game_id = ? AND gu.user_id = ? LIMIT 1 "); $stmt->execute([ $gameId, $userId ]); $gameUser = $stmt->fetch(); if (!$gameUser) { die('Game user not found.'); } /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * Author permission belongs to the user/game relationship, not to the user * globally. wf_game_users.role is therefore the canonical source. */ $canUseAuthorMode = strtolower( trim( (string)( $gameUser['game_user_role'] ?? '' ) ) ) === 'author'; // ================================================================ // RUNTIME STATE // ================================================================ $state = null; if (!empty($gameUser['state'])) { $decodedState = json_decode( $gameUser['state'], true ); if (is_array($decodedState)) { $state = $decodedState; } } /* * WAYFARER_ENTITY_RUNTIME_STATE_PAGE_RECOVERY_V49 * * Existing development playthroughs should gain Entity state immediately on * page load. This is additive and does not overwrite current runtime values. */ if ($state !== null) { $runtimeRecoveryChanged = wayfarerRuntimeEnsureEntityState( $pdo, (int)$gameId, $state ); /* WAYFARER_PLAYER_TRAITS_PAGE_RECOVERY_V52 */ if ( wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$gameId, $state ) ) { $runtimeRecoveryChanged = true; } if ($runtimeRecoveryChanged) { $encodedRecoveredState = wayfarerRuntimeEncodeState( $state ); $entityRecoveryStmt = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $entityRecoveryStmt->execute([ $encodedRecoveredState, (int)$gameUser['game_user_id'], ]); /* * Keep Author Debug and any later page component on the same snapshot. */ $gameUser['state'] = $encodedRecoveredState; } } // ================================================================ // INITIALIZE GAME // ================================================================ $initializationError = ''; if ( $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'initialize' && $state === null ) { try { wayfarerRuntimeInitializeGame( $pdo, (int)$gameUser['game_user_id'], [ 'player_names' => $_POST['player_names'] ?? [], 'choices' => $_POST['setup_choices'] ?? [], ] ); /* * Reload from persisted state so every play-page component sees the * exact same initialized snapshot. */ header('Location: index.php'); exit; } catch (Throwable $exception) { $initializationError = $exception->getMessage(); } } // ================================================================ // AUTHOR MODE: GAME TRAIT DEFINITIONS // ================================================================ $gameTraitDefinitions = []; $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } foreach ($gameConfig['traits'] ?? [] as $trait) { if (empty($trait['id'])) { continue; } $gameTraitDefinitions[$trait['id']] = $trait; } ?> <?= htmlspecialchars($gameUser['game_name']) ?>

Player:



================================================================================ FILE: update-backups/v61-20260925-224153/admin/games/edit.php ================================================================================ null, 'name' => '', 'slug' => '', 'description' => '', 'instructions' => '', 'player_slots' => 0, 'config' => null, 'status' => 'draft' ]; $traits = []; $playerTraits = []; $config = []; /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ $gameTickEffects = []; $gameplayTickEffects = []; $startingChoices = []; $startingChoicesEditor = []; $startingChoiceItemTargets = []; /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 */ $gameEndings = []; $gameEndingsEditor = []; $error = ''; // ================================================================ // LOAD EXISTING GAME // ================================================================ if (isset($_GET['id'])) { $stmt = $pdo->prepare(" SELECT * FROM wf_games WHERE id = ? "); $stmt->execute([(int)$_GET['id']]); $existingGame = $stmt->fetch(); if (!$existingGame) { die('Game not found.'); } $game = array_merge($game, $existingGame); if (!empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } if ( isset($config['traits']) && is_array($config['traits']) ) { $traits = $config['traits']; } /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ if ( isset($config['player_traits']) && is_array($config['player_traits']) ) { $playerTraits = array_values( $config['player_traits'] ); } /* WAYFARER_LOAD_GAME_TICK_EFFECTS_V33 */ if ( isset($config['tick_effects']) && is_array($config['tick_effects']) ) { $gameTickEffects = array_values( $config['tick_effects'] ); } if ( isset($config['setup']['choices']) && is_array($config['setup']['choices']) ) { $startingChoices = array_values( $config['setup']['choices'] ); } /* WAYFARER_GAME_ENDINGS_V45 */ if ( isset($config['endings']) && is_array($config['endings']) ) { $gameEndings = array_values( $config['endings'] ); } } $gameplayTickEffects = $gameTickEffects; $startingChoicesEditor = $startingChoices; $gameEndingsEditor = $gameEndings; // ================================================================ // SAVE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $game['id'] = !empty($_POST['id']) ? (int)$_POST['id'] : null; $game['name'] = trim($_POST['name'] ?? ''); $game['slug'] = trim($_POST['slug'] ?? ''); $game['description'] = trim($_POST['description'] ?? ''); $game['instructions'] = trim($_POST['instructions'] ?? ''); $game['player_slots'] = max(0, (int)($_POST['player_slots'] ?? 0)); $game['status'] = $_POST['status'] ?? 'draft'; // ------------------------------------------------------------ // BUILD GAME + PLAYER TRAIT DEFINITIONS // WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 // ------------------------------------------------------------ $traitResult = buildTraitsFromPost( $_POST['traits'] ?? [], ['status'] ); $traits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } $playerTraitResult = buildTraitsFromPost( $_POST['player_traits'] ?? [], ['status'] ); $playerTraits = $playerTraitResult['traits']; if ( $error === '' && $playerTraitResult['error'] !== '' ) { $error = 'Player Traits: ' . $playerTraitResult['error']; } /* WAYFARER_GAME_SETTINGS_UNIVERSAL_TARGETS_V56 */ $gameplayRuntimeTargets = gameplayRuntimeStateTargets( $pdo, (int)($game['id'] ?? 0), $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ) ); /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 */ $startingChoiceItemTargets = wayfarerStartingChoiceItemQuantityTargets( $pdo, (int)($game['id'] ?? 0) ); /* WAYFARER_BUILD_STARTING_CHOICES_V36 */ /* WAYFARER_STARTING_CHOICES_CANONICAL_EMPTY_V36_1 */ $startingChoicesSubmitted = isset($_POST['setup_choices_present']) || array_key_exists( 'setup_choices', $_POST ); $postedStartingChoices = isset($_POST['setup_choices']) && is_array($_POST['setup_choices']) ? array_values( $_POST['setup_choices'] ) : []; $startingChoicesEditor = $postedStartingChoices; if ($error === '') { $startingChoiceResult = wayfarerBuildStartingChoicesFromPost( $postedStartingChoices, $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ), $startingChoiceItemTargets, $gameplayRuntimeTargets ); if ( $startingChoiceResult['error'] !== '' ) { $error = $startingChoiceResult['error']; } else { $startingChoices = $startingChoiceResult['choices']; $startingChoicesEditor = $startingChoices; } } /* WAYFARER_BUILD_GAME_ENDINGS_V45 */ $postedGameEndings = isset($_POST['endings']) && is_array($_POST['endings']) ? array_values( $_POST['endings'] ) : []; $gameEndingsEditor = $postedGameEndings; if ($error === '') { $endingResult = wayfarerBuildGameEndingsFromPost( $pdo, (int)($game['id'] ?? 0), $postedGameEndings, $traits ); if ( $endingResult['error'] !== '' ) { $error = $endingResult['error']; } else { $gameEndings = $endingResult['endings']; $gameEndingsEditor = $gameEndings; } } /* WAYFARER_BUILD_GAME_TICK_EFFECTS_V33 */ $postedGameTickEffects = isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ? array_values( $_POST['tick_effects'] ) : []; $gameplayTickEffects = $postedGameTickEffects; if ($error === '') { $gameTickEffectResult = gameplayBuildTickEffectsFromPost( $gameplayRuntimeTargets, $postedGameTickEffects, $gameplayRuntimeTargets ); if ( $gameTickEffectResult['error'] !== '' ) { $error = $gameTickEffectResult['error']; } else { $gameTickEffects = $gameTickEffectResult['effects']; $gameplayTickEffects = $gameTickEffects; } } // ------------------------------------------------------------ // VALIDATE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['name'] === '') { $error = 'Game name is required.'; } elseif ($game['slug'] === '') { $error = 'Slug is required.'; } } // ------------------------------------------------------------ // BUILD CONFIG JSON // ------------------------------------------------------------ if ($error === '') { /* * Preserve any other configuration already stored for the game. * Traits own only the "traits" section of config. */ if (empty($config) && !empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } $startingRegionId = !empty($_POST['starting_region_id']) ? (int)$_POST['starting_region_id'] : null; if ($startingRegionId !== null) { $config['starting_region_id'] = $startingRegionId; } else { unset($config['starting_region_id']); } $config['traits'] = array_values( $traits ); /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ $config['player_traits'] = array_values( $playerTraits ); /* WAYFARER_SAVE_GAME_TICK_EFFECTS_V33 */ if ($gameTickEffects) { $config['tick_effects'] = array_values( $gameTickEffects ); } else { unset( $config['tick_effects'] ); } /* WAYFARER_SAVE_STARTING_CHOICES_V36 */ /* * WAYFARER_STARTING_CHOICES_CANONICAL_EMPTY_V36_1 * * If the Setup editor participated in this submission, its current * collection is the canonical collection. In particular, deleting the * final card must persist an explicit empty array rather than relying * on the absence of setup_choices in POST. */ if ($startingChoicesSubmitted) { if ( !isset($config['setup']) || !is_array($config['setup']) ) { $config['setup'] = []; } $config['setup']['choices'] = array_values( $startingChoices ); } /* WAYFARER_SAVE_GAME_ENDINGS_V45 */ $config['endings'] = array_values( $gameEndings ); $game['config'] = json_encode( $config, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ($game['config'] === false) { $error = 'Unable to encode game configuration.'; } } // ------------------------------------------------------------ // WRITE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['id']) { $stmt = $pdo->prepare(" UPDATE wf_games SET name = ?, slug = ?, description = ?, instructions = ?, player_slots = ?, config = ?, status = ? WHERE id = ? "); $stmt->execute([ $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'], $game['id'] ]); } else { $uuid = bin2hex(random_bytes(16)); $stmt = $pdo->prepare(" INSERT INTO wf_games ( uuid, name, slug, description, instructions, player_slots, config, status ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $uuid, $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'] ]); $game['id'] = (int)$pdo->lastInsertId(); } /* * WAYFARER_STAY_ON_GAME_SETTINGS_V38 * * Successful saves remain on the same Game Settings record and reopen * the tab the author was using. */ $savedGameTab = (string)( $_POST['active_tab'] ?? 'basics' ); if ( !in_array( $savedGameTab, [ 'basics', 'setup', 'traits', 'gameplay', 'endings', ], true ) ) { $savedGameTab = 'basics'; } header( 'Location: edit.php?id=' . (int)$game['id'] . '&tab=' . rawurlencode( $savedGameTab ) ); exit; } } // ================================================================ // LOAD REGIONS FOR STARTING REGION // ================================================================ $regions = []; if (!empty($game['id'])) { $regionStmt = $pdo->prepare(" SELECT id, name FROM wf_regions WHERE game_id = ? ORDER BY name "); $regionStmt->execute([ $game['id'] ]); $regions = $regionStmt->fetchAll(); } /* WAYFARER_GAME_SETTINGS_UNIVERSAL_TARGETS_RENDER_V56 */ $gameplayRuntimeTargets = gameplayRuntimeStateTargets( $pdo, (int)($game['id'] ?? 0), $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ) ); /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_RENDER_V54 */ $startingChoiceItemTargets = wayfarerStartingChoiceItemQuantityTargets( $pdo, (int)($game['id'] ?? 0) ); ?> <?= $game['id'] ? 'Edit Game' : 'New Game' ?> - Wayfarer


Example: stormgate

================================================================================ FILE: update-backups/v61-20260925-224153/config/builder-types.php ================================================================================ [ 'label' => 'Regions', 'singular' => 'Region', 'table' => 'wf_regions', 'list_field' => 'name', 'traits' => true, /* * Permanent deletion is available from the edit screen. * A Region cannot be deleted while Locations still belong to it. */ 'delete' => [ 'enabled' => true, 'dependencies' => [ [ 'table' => 'wf_locations', 'foreign_key' => 'region_id', 'label' => 'Locations' ] ] ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'navigation_type' => [ 'label' => 'Navigation Type', 'type' => 'select', 'required' => true, 'options' => [ 'linear' => 'Linear', 'grid' => 'Grid', 'freeform' => 'Freeform' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ] ], 'locations' => [ 'label' => 'Locations', 'singular' => 'Location', 'table' => 'wf_locations', 'list_field' => 'name', 'traits' => true, 'list_columns' => [ 'name' => [ 'label' => 'Name', 'source' => 'field', 'field' => 'name', 'sortable' => true ], 'region' => [ 'label' => 'Region', 'source' => 'relationship', 'relationship' => 'region_id', 'sortable' => true ], 'position' => [ 'label' => 'Position', 'source' => 'config', 'field' => 'position', 'type' => 'number', 'sortable' => true ] ], /* * Permanent deletion is available from the edit screen. */ 'delete' => [ 'enabled' => true ], 'scope' => [ 'type' => 'region', 'field' => 'region_id', 'scope_table' => 'wf_regions', 'scope_key' => 'id', 'game_field' => 'game_id' ], 'relationships' => [ 'region_id' => [ 'label' => 'Region', 'type' => 'select_record', 'table' => 'wf_regions', 'value_field' => 'id', 'label_field' => 'name', 'game_field' => 'game_id', 'required' => true ] ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], 'config_fields' => [ 'position' => [ 'label' => 'Position', 'type' => 'number', 'min' => 0, 'required' => true, 'condition' => [ 'relationship' => 'region_id', 'source_table' => 'wf_regions', 'source_key' => 'id', 'source_field' => 'navigation_type', 'equals' => 'linear' ] ] ] ], 'entities' => [ 'label' => 'Entities', 'singular' => 'Entity', 'table' => 'wf_entities', 'list_field' => 'name', 'traits' => true, /* * Permanent deletion is available from the edit screen. * Additional dependency rules can be added here later as * Storylets and other authored objects begin referencing Entities. */ 'delete' => [ 'enabled' => true ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true ], 'description' => [ 'label' => 'Description', 'type' => 'textarea' ], 'entity_type' => [ 'label' => 'Entity Type', 'type' => 'select', 'required' => true, 'default' => 'item', 'options' => [ 'item' => 'Item', 'character' => 'Character' ] ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], /* * System traits use the same stored config.traits collection as * author-created traits. They are locked in the authoring UI. */ 'system_traits' => [ 'selector_field' => 'entity_type', 'after_field' => 'entity_type', 'groups' => [ 'item' => [ 'label' => 'Item Settings', 'help' => 'These are required Item properties used by Wayfarer. ' . 'They cannot be removed or renamed.', 'traits' => [ 'quantity' => [ /* * WAYFARER_ITEM_QUANTITY_CONTEXT_V44 */ 'label' => 'Starting Quantity', 'type' => 'number', 'default' => 0, 'min' => 0, 'required' => true, /* * The minimum is a Wayfarer system rule, not an * author-editable gameplay setting. */ 'show_system_min' => true, 'min_label' => 'Minimum Quantity', 'min_help' => 'Wayfarer Item quantities cannot fall below 0.', 'allow_author_max' => true, 'max_label' => 'Maximum Quantity', 'max_help' => 'The maximum quantity the player can hold during gameplay. ' . 'Leave blank for no maximum.', 'help' => 'Sets the quantity of this Item when gameplay is initialized. ' . 'Effects may increase or decrease the quantity during play. ' . 'A Starting Quantity of 0 means the player begins without this Item.' ], 'inventory_visibility' => [ 'label' => 'Inventory Visibility', 'type' => 'select', 'default' => 'while_possessed', 'required' => true, 'options' => [ 'while_possessed' => 'Only while possessed', 'once_discovered' => 'Once discovered', 'always' => 'Always', 'never' => 'Never' ], 'help' => 'Controls when this item appears in the player inventory. ' . '"Only while possessed" hides the item whenever its quantity ' . 'is 0. "Once discovered" hides it until the player first ' . 'obtains or discovers it, then keeps it visible even if its ' . 'quantity later returns to 0. "Always" shows the item from ' . 'the beginning of the game. "Never" keeps the item hidden ' . 'from the player inventory while still allowing gameplay ' . 'conditions and effects to use it.' ] ] ], 'character' => [ 'label' => 'Character Settings', 'help' => 'These are required Character properties used by Wayfarer. ' . 'They cannot be removed or renamed.', 'traits' => [ 'party_member' => [ 'label' => 'Starts in Party', 'type' => 'binary', 'default' => 0, 'required' => true, 'help' => 'Determines whether this character begins the game ' . 'as a member of the player party. No stores 0; ' . 'Yes stores 1. Storylets can later change this ' . 'state to add or remove the character from the party.' ] ] ] ] ] ], /* WAYFARER_STORYLET_BUILDER_V56 */ 'storylets' => [ 'label' => 'Storylets', 'singular' => 'Storylet', 'table' => 'wf_storylets', 'list_field' => 'name', 'list_columns' => [ 'name' => [ 'label' => 'Name', 'source' => 'field', 'field' => 'name', 'sortable' => true ], 'parent' => [ 'label' => 'Parent', 'source' => 'relationship', 'relationship' => 'parent_storylet_id', 'sortable' => true ], 'trigger' => [ 'label' => 'Trigger', 'source' => 'field', 'field' => 'trigger_type', 'sortable' => true ], 'status' => [ 'label' => 'Status', 'source' => 'field', 'field' => 'status', 'sortable' => true ] ], /* * A Storylet with children cannot be permanently removed until its * Branch Storylets have been detached or deleted. */ 'delete' => [ 'enabled' => true, 'dependencies' => [ [ 'table' => 'wf_storylets', 'foreign_key' => 'parent_storylet_id', 'label' => 'Branch Storylets' ] ] ], 'scope' => [ 'type' => 'game', 'field' => 'game_id' ], 'relationships' => [ 'parent_storylet_id' => [ 'label' => 'Parent Storylet', 'type' => 'select_record', 'table' => 'wf_storylets', 'value_field' => 'id', 'label_field' => 'name', 'game_field' => 'game_id', 'required' => false, 'exclude_current' => true, 'help' => 'Leave blank for a root Storylet. Select a parent to make ' . 'this Storylet one of that Storylet\'s player Branches.' ] ], 'fields' => [ 'name' => [ 'label' => 'Name', 'type' => 'text', 'required' => true, 'help' => 'Author-facing Storylet name and default player-facing title.' ], 'description' => [ 'label' => 'Narrative / Description', 'type' => 'textarea', 'help' => 'Narrative shown when this Storylet opens. It may also be ' . 'used as preview text when the Storylet is offered.' ], 'branch_label' => [ 'label' => 'Branch Label', 'type' => 'text', 'help' => 'Used when this Storylet appears as a child Branch. Leave ' . 'blank to use the Storylet Name.' ], 'branch_sort_order' => [ 'label' => 'Branch Order', 'type' => 'number', 'min' => 0, 'default' => 0, 'help' => 'Lower numbers appear first among sibling Branches. Ignored ' . 'for root Storylets.' ], 'trigger_type' => [ 'label' => 'Trigger Type', 'type' => 'select', 'required' => true, 'default' => 'manual', 'options' => [ 'manual' => 'Manual', 'automatic' => 'Automatic', 'chance' => 'Chance' ], 'help' => 'Manual: offer while eligible. Automatic: fire when it becomes ' . 'eligible. Chance: while eligible, roll the base chance each tick.' ], 'max_occurrences' => [ 'label' => 'Maximum Occurrences', 'type' => 'number', 'min' => 1, 'help' => 'Leave blank for unlimited occurrences.' ], 'status' => [ 'label' => 'Status', 'type' => 'select', 'required' => true, 'default' => 'draft', 'options' => [ 'draft' => 'Draft', 'active' => 'Active', 'inactive' => 'Inactive' ] ] ], 'config_fields' => [ 'priority' => [ 'label' => 'Priority', 'type' => 'number', 'default' => 100, 'help' => 'Author-controlled gameplay order for competing Storylets. ' . 'Lower numbers are evaluated first.' ], 'tick_cost' => [ 'label' => 'Tick Cost', 'type' => 'number', 'min' => 0, 'default' => 0, 'help' => 'How many normal gameplay ticks resolving this Storylet consumes. ' . '0 consumes no time; 1 or more runs the normal tick pipeline.' ], 'chance_percent' => [ 'label' => 'Base Chance %', 'type' => 'number', 'min' => 0, 'max' => 100, 'step' => 0.01, 'help' => 'Used only by Chance Storylets. Eligibility is checked first; ' . 'then this percentage is rolled on each gameplay tick.' ], 'interrupts_travel' => [ 'label' => 'Interrupts Travel', 'type' => 'select', 'default' => '0', 'options' => [ '0' => 'No', '1' => 'Yes' ], 'help' => 'Used by Automatic and Chance Storylets. If Yes, firing the ' . 'Storylet stops the current travel activity. Manual Storylets ' . 'are selected by the player and do not use this setting.' ] ] ] ]; ================================================================================ FILE: update-backups/v61-20260925-224153/play/api/tick.php ================================================================================ false, 'error' => 'POST required.', ]); exit; } if (session_status() !== PHP_SESSION_ACTIVE) { session_start(); } require_once __DIR__ . '/../../config/database.php'; require_once __DIR__ . '/../includes/runtime.php'; function wayfarerTickRespond( int $status, array $payload ): void { http_response_code($status); echo json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); exit; } try { $rawInput = file_get_contents( 'php://input' ); $input = json_decode( $rawInput ?: '{}', true ); if (!is_array($input)) { wayfarerTickRespond( 400, [ 'ok' => false, 'error' => 'Invalid JSON request.', ] ); } $csrfToken = isset($input['csrf_token']) ? (string)$input['csrf_token'] : ''; $sessionToken = isset($_SESSION['wayfarer_tick_csrf']) ? (string)$_SESSION['wayfarer_tick_csrf'] : ''; if ( $csrfToken === '' || $sessionToken === '' || !hash_equals( $sessionToken, $csrfToken ) ) { wayfarerTickRespond( 403, [ 'ok' => false, 'error' => 'Invalid tick token.', ] ); } $requestedId = isset($input['game_user_id']) ? (int)$input['game_user_id'] : null; $gameUserId = wayfarerRuntimeResolveGameUserId( $pdo, $requestedId ); $result = wayfarerRuntimeAdvanceTick( $pdo, $gameUserId ); wayfarerTickRespond( 200, [ 'ok' => true, 'tick' => $result['tick'], 'game_user_id' => $result['game_user_id'], 'state' => $result['state'], /* WAYFARER_PLAYER_CONTEXT_API_V46_1 */ 'context' => $result['context'] ?? null, /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 * The browser stops automatic advancement immediately when the * runtime reports an interrupting presentation. */ 'pause' => !empty( $result['pause'] ), 'pause_reason' => (string)( $result['pause_reason'] ?? '' ), 'presentation' => $result['presentation'] ?? null, /* WAYFARER_GAME_ENDINGS_TICK_API_V45 */ 'ending' => $result['ending'] ?? null, /* WAYFARER_STORYLET_ACTIONS_TICK_API_V59 */ 'actions' => $result['actions'] ?? [], ] ); } catch (Throwable $exception) { wayfarerTickRespond( 500, [ 'ok' => false, 'error' => $exception->getMessage(), ] ); } ================================================================================ FILE: update-backups/v61-20260925-224153/play/assets/play.css ================================================================================ /* |-------------------------------------------------------------------------- | Wayfarer Play Shell v20 |-------------------------------------------------------------------------- */ html, body { height: 100%; } body.wayfarer-play-page { margin: 0; overflow: hidden; background: #eef1f5; color: #20242a; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.45; } .wayfarer-play-page * { box-sizing: border-box; } .wayfarer-play-page hr { display: none; } .wayfarer-play-shell { height: 100vh; display: flex; flex-direction: column; overflow: hidden; } .wayfarer-play-shell-header { flex: 0 0 auto; min-height: 68px; padding: 12px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; background: #ffffff; border-bottom: 1px solid #d8dbe2; } .wayfarer-play-title { min-width: 0; } .wayfarer-play-title h1 { margin: 0; font-size: 1.35rem; line-height: 1.2; } .wayfarer-play-title p { margin: 4px 0 0; color: #667085; font-size: 0.9rem; } .wayfarer-shell-actions { display: flex; align-items: center; gap: 8px; } .wayfarer-shell-button { border: 1px solid #b8bdc7; border-radius: 6px; padding: 7px 10px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 600; } .wayfarer-shell-button:hover { background: #f6f7f9; } .wayfarer-play-main { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 7fr) minmax(330px, 3fr); gap: 12px; padding: 12px; overflow: hidden; } .wayfarer-player-pane, .wayfarer-author-pane { min-width: 0; min-height: 0; background: #ffffff; border: 1px solid #d8dbe2; border-radius: 10px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); } .wayfarer-player-pane { overflow: auto; padding: 22px; } .wayfarer-player-pane > h2:first-child { margin-top: 0; } #wayfarer-player-scene { margin-top: 10px; } #wayfarer-player-scene h2 { margin-top: 0; } .wayfarer-author-pane { display: flex; flex-direction: column; overflow: hidden; } .wayfarer-author-header { flex: 0 0 auto; padding: 14px 16px 10px; border-bottom: 1px solid #e3e6eb; } .wayfarer-author-header h2 { margin: 0; font-size: 1.05rem; } .wayfarer-author-header p { margin: 6px 0 0; font-size: 0.85rem; } .wayfarer-author-header a { color: #2f5ea8; } .wayfarer-author-toolbar { flex: 0 0 auto; padding: 10px 14px; background: #f6f7f9; border-bottom: 1px solid #e3e6eb; } .wayfarer-author-toolbar #wayfarer-tick-test { margin: 0; } .wayfarer-author-toolbar #wayfarer-tick-test > h2 { margin: 0 0 6px; font-size: 0.9rem; } .wayfarer-author-toolbar #wayfarer-tick-test > p { margin: 5px 0; font-size: 0.82rem; } .wayfarer-author-toolbar button { margin-right: 4px; margin-bottom: 2px; padding: 5px 8px; } #wayfarer-tick-message { min-height: 1.2em; } .wayfarer-author-tabs { flex: 0 0 auto; display: flex; gap: 2px; padding: 8px 10px 0; background: #ffffff; border-bottom: 1px solid #d8dbe2; } .wayfarer-author-tab { appearance: none; border: 0; border-bottom: 2px solid transparent; padding: 8px 10px 7px; background: transparent; color: #667085; cursor: pointer; font: inherit; font-size: 0.86rem; font-weight: 600; } .wayfarer-author-tab:hover { color: #20242a; } .wayfarer-author-tab.is-active { color: #2f5ea8; border-bottom-color: #2f5ea8; } .wayfarer-author-content { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 14px 16px 18px; } .wayfarer-author-tab-panel { display: none; } .wayfarer-author-tab-panel.is-active { display: block; } .wayfarer-author-tab-panel > :first-child { margin-top: 0; } .wayfarer-author-tab-panel h3 { margin-top: 0; font-size: 1rem; } .wayfarer-author-tab-panel h4 { margin-bottom: 8px; } .wayfarer-author-tab-panel table { width: 100%; border-collapse: collapse; font-size: 0.84rem; } .wayfarer-author-tab-panel th, .wayfarer-author-tab-panel td { padding: 6px 7px; text-align: left; vertical-align: top; } .wayfarer-author-tab-panel pre { max-width: 100%; overflow: auto; font-size: 0.78rem; } #wayfarer-author-debug-panel details { margin-top: 0 !important; } #wayfarer-author-debug-panel summary { display: none; } #wayfarer-author-debug-panel details:not([open]) > *:not(summary) { display: block; } #wayfarer-author-debug-panel details > pre { display: block; max-height: none !important; margin-top: 0 !important; } .wayfarer-player-meta { margin-bottom: 14px; padding: 10px 12px; border-radius: 7px; background: #f6f7f9; color: #667085; font-size: 0.86rem; } .wayfarer-player-meta h2 { margin: 0 0 4px; color: #20242a; font-size: 0.95rem; } .wayfarer-player-meta p { margin: 0; } .wayfarer-play-shell.author-collapsed .wayfarer-play-main { grid-template-columns: minmax(0, 1fr); } .wayfarer-play-shell.author-collapsed .wayfarer-author-pane { display: none; } @media (max-width: 900px) { body.wayfarer-play-page { overflow: auto; } .wayfarer-play-shell { height: auto; min-height: 100vh; overflow: visible; } .wayfarer-play-main { display: block; overflow: visible; padding: 8px; } .wayfarer-player-pane, .wayfarer-author-pane { overflow: visible; margin-bottom: 10px; } .wayfarer-author-pane { min-height: 480px; } .wayfarer-author-content { overflow: visible; } .wayfarer-play-shell.author-collapsed .wayfarer-author-pane { display: none; } } /* ------------------------------------------------------------------ GAME START SCREEN WAYFARER_PLAYER_START_SCREEN_V36 ------------------------------------------------------------------ */ /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * The start screen no longer implies "no Author Mode." Authors may inspect * the Author panel before initialization. The existing author-collapsed class * controls the one-column layout for normal players and collapsed authors. */ .wayfarer-start-screen { width: min(860px, 100%); margin: 0 auto; } .wayfarer-start-screen-heading { margin-bottom: 24px; } .wayfarer-start-screen-heading h2 { margin: 0 0 12px; font-size: 1.65rem; } .wayfarer-start-introduction { max-width: 72ch; color: #4b5563; line-height: 1.65; } .wayfarer-start-error { margin-bottom: 18px; padding: 10px 12px; border: 1px solid #e4a7ad; border-radius: 7px; background: #fff4f5; color: #8b1e2d; } .wayfarer-start-form { display: grid; gap: 22px; } .wayfarer-start-section, .wayfarer-start-choice { margin: 0; padding: 18px; border: 1px solid #d8dbe2; border-radius: 9px; background: #ffffff; } .wayfarer-start-section h3, .wayfarer-start-choice legend { margin: 0 0 14px; color: #20242a; font-size: 1.05rem; font-weight: 700; } .wayfarer-start-choice legend { padding: 0 6px; } .wayfarer-start-names { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } .wayfarer-start-names label { display: grid; gap: 5px; font-weight: 600; } .wayfarer-start-names input { width: 100%; padding: 9px 10px; border: 1px solid #b8bdc7; border-radius: 6px; font: inherit; } .wayfarer-start-choice { display: grid; gap: 10px; } .wayfarer-start-option { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: start; padding: 12px 13px; border: 1px solid #d8dbe2; border-radius: 7px; cursor: pointer; } .wayfarer-start-option:hover { background: #f6f7f9; } .wayfarer-start-option input { margin-top: 4px; } .wayfarer-start-option-copy { display: grid; gap: 4px; } .wayfarer-start-option-copy > span { color: #667085; font-size: 0.92rem; } .wayfarer-start-actions { display: flex; justify-content: flex-end; } .wayfarer-start-button { padding: 10px 18px; border: 1px solid #244b88; border-radius: 7px; background: #2f5ea8; color: #ffffff; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-start-button:hover { background: #244b88; } @media (max-width: 700px) { .wayfarer-start-screen { width: 100%; } .wayfarer-start-section, .wayfarer-start-choice { padding: 14px; } } /* ------------------------------------------------------------------ PLAYER ACTION SCREEN WAYFARER_ACTION_SCREEN_V39 ------------------------------------------------------------------ */ .wayfarer-action-screen { margin-top: 14px; padding: 18px; border: 1px solid #d8dbe2; border-radius: 10px; background: #ffffff; box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); } .wayfarer-action-screen[hidden], .wayfarer-travel-controls[hidden] { display: none !important; } .wayfarer-action-heading { margin-bottom: 14px; } .wayfarer-action-heading h2 { margin: 0 0 4px; font-size: 1.25rem; } .wayfarer-action-heading p { margin: 0; color: #667085; } .wayfarer-action-list { display: grid; gap: 10px; } .wayfarer-action-button { width: 100%; padding: 12px 14px; border: 1px solid #214b86; border-radius: 8px; background: #245a9b; color: #ffffff; cursor: pointer; font: inherit; font-weight: 700; text-align: left; } .wayfarer-action-button:hover { background: #1e4c84; } .wayfarer-action-button:disabled { cursor: default; opacity: 0.55; } /* ------------------------------------------------------------------ IN-CARD ACTION UTILITY VIEWS WAYFARER_ACTION_UTILITY_VIEWS_V48 ------------------------------------------------------------------ */ .wayfarer-action-utility-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 2px; } .wayfarer-action-utility-button, .wayfarer-action-back-button { padding: 10px 12px; border: 1px solid #b8bdc7; border-radius: 7px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 650; } .wayfarer-action-utility-button { width: 100%; text-align: center; } .wayfarer-action-utility-button:hover, .wayfarer-action-back-button:hover { border-color: #98a2b3; background: #f6f7f9; } .wayfarer-action-subview[hidden], .wayfarer-action-heading[hidden], .wayfarer-action-list[hidden] { display: none !important; } .wayfarer-action-subview-heading { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 18px; } .wayfarer-action-subview-heading h2 { margin: 0; font-size: 1.25rem; } .wayfarer-action-back-button { flex: 0 0 auto; } .wayfarer-action-subview-body { min-height: 90px; } /* ------------------------------------------------------------------ ENTITY INVENTORY / STATUS PRESENTATION WAYFARER_ENTITY_PRESENTATION_V49 ------------------------------------------------------------------ */ .wayfarer-inventory-list, .wayfarer-party-list { display: grid; gap: 10px; } .wayfarer-inventory-item, .wayfarer-party-member, .wayfarer-status-section { padding: 13px 14px; border: 1px solid #d8dbe2; border-radius: 8px; background: #f8fafc; } .wayfarer-inventory-item-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; } .wayfarer-inventory-quantity { flex: 0 0 auto; color: #667085; font-size: 0.9rem; font-weight: 650; } .wayfarer-status-section + .wayfarer-status-section { margin-top: 12px; } .wayfarer-status-section > h3 { margin: 0 0 10px; font-size: 1rem; } .wayfarer-status-player-name { margin: 0; font-weight: 700; } .wayfarer-party-member { background: #ffffff; } .wayfarer-party-member h4 { margin: 0; font-size: 0.98rem; } /* WAYFARER_PLAYER_TRAITS_STATUS_V52 */ .wayfarer-player-member h4::after { content: "Player"; margin-left: 8px; color: #667085; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; } .wayfarer-status-values { margin: 10px 0 0; } .wayfarer-status-values > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; padding: 6px 0; border-top: 1px solid #e3e6eb; } .wayfarer-status-values dt { color: #4b5563; } .wayfarer-status-values dd { margin: 0; font-weight: 700; text-align: right; } .wayfarer-status-muted { margin: 8px 0 0; color: #667085; font-size: 0.88rem; } /* ------------------------------------------------------------------ ENTITY DETAIL VIEW WAYFARER_ENTITY_DETAIL_VIEW_V50 ------------------------------------------------------------------ */ .wayfarer-entity-open-button { appearance: none; border: 0; padding: 0; background: transparent; color: #2f5ea8; cursor: pointer; font: inherit; font-weight: 750; text-align: left; } .wayfarer-entity-open-button:hover { text-decoration: underline; } .wayfarer-entity-open-button:focus-visible { outline: 2px solid #2f5ea8; outline-offset: 3px; border-radius: 2px; } .wayfarer-status-player-names { display: grid; gap: 4px; } .wayfarer-status-player-names .wayfarer-status-player-name { margin: 0; } .wayfarer-entity-detail-eyebrow { margin: 0 0 3px; color: #667085; font-size: 0.78rem; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; } .wayfarer-entity-detail-layout { display: grid; grid-template-columns: minmax(180px, 280px) minmax(0, 1fr); gap: 22px; align-items: start; } .wayfarer-entity-detail-layout:not(:has(.wayfarer-entity-detail-media)) { grid-template-columns: minmax(0, 1fr); } .wayfarer-entity-detail-media { margin: 0; } .wayfarer-entity-detail-media img { display: block; width: 100%; max-height: 340px; object-fit: contain; border-radius: 8px; background: #f6f7f9; } .wayfarer-entity-detail-description { margin: 0 0 16px; line-height: 1.6; } .wayfarer-entity-detail-copy > :first-child { margin-top: 0; } @media (max-width: 700px) { .wayfarer-entity-detail-layout { grid-template-columns: 1fr; } .wayfarer-entity-detail-media img { max-height: 280px; } } .wayfarer-action-empty { margin: 0; color: #667085; font-size: 0.92rem; } .wayfarer-travel-controls { margin-top: 14px; padding: 12px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid #d8dbe2; border-radius: 10px; background: #ffffff; } .wayfarer-travel-status { display: flex; align-items: center; gap: 9px; font-weight: 700; } .wayfarer-travel-indicator { width: 9px; height: 9px; border-radius: 999px; background: currentColor; } .wayfarer-travel-pause { padding: 8px 12px; border: 1px solid #aab4c3; border-radius: 7px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 600; } .wayfarer-travel-pause:hover { background: #f6f7f9; } @media (max-width: 620px) { .wayfarer-action-screen { padding: 14px; } .wayfarer-travel-controls { align-items: stretch; flex-direction: column; } .wayfarer-travel-pause { width: 100%; } .wayfarer-action-utility-row { grid-template-columns: 1fr; } } /* ------------------------------------------------------------------ LOCATION / STORY PRESENTATION WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 ------------------------------------------------------------------ */ /* * Scene prose now belongs to Presentation Mode rather than the normal Action * page. The server-rendered scene remains in the DOM temporarily for runtime * compatibility but is not player-facing. */ #wayfarer-player-scene { display: none; } .wayfarer-action-parent-context { margin-bottom: 4px !important; font-size: 0.85rem; font-weight: 600; } /* ------------------------------------------------------------------ STORYLET ACTIONS / BRANCHES WAYFARER_STORYLET_ACTION_RUNTIME_V59 ------------------------------------------------------------------ */ .wayfarer-storylet-action-list { display: grid; gap: 10px; } .wayfarer-storylet-action-list:empty { display: none; } .wayfarer-storylet-branch-list { width: 100%; display: grid; gap: 10px; } .wayfarer-presentation-actions.wayfarer-has-storylet-branches > div:not(.wayfarer-storylet-branch-list) { display: none; } .wayfarer-storylet-branch-prompt { margin: 0 0 4px; color: #b8c0cc; font-weight: 700; } .wayfarer-storylet-branch-button { width: 100%; padding: 12px 14px; border: 1px solid #f8fafc; border-radius: 8px; background: transparent; color: #f8fafc; cursor: pointer; font: inherit; font-weight: 700; text-align: left; } .wayfarer-storylet-branch-button:hover, .wayfarer-storylet-branch-button:focus-visible { background: #f8fafc; color: #15181d; } .wayfarer-storylet-branch-button:disabled { cursor: default; opacity: 0.55; } .wayfarer-presentation-screen { position: fixed; z-index: 10000; inset: 0; overflow-y: auto; padding: clamp(24px, 6vh, 72px) 24px; background: #15181d; color: #f8fafc; } .wayfarer-presentation-screen[hidden] { display: none !important; } .wayfarer-presentation-inner { width: min(1040px, 100%); min-height: calc(100vh - clamp(48px, 12vh, 144px)); margin: 0 auto; display: flex; flex-direction: column; justify-content: center; } .wayfarer-presentation-media { width: 100%; margin: 0 0 26px; overflow: hidden; border-radius: 12px; background: #0d0f12; } .wayfarer-presentation-media img { display: block; width: 100%; max-height: 52vh; object-fit: cover; } .wayfarer-presentation-copy { width: min(760px, 100%); margin: 0 auto; } .wayfarer-presentation-eyebrow { margin: 0 0 8px; color: #b8c0cc; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .wayfarer-presentation-copy h1 { margin: 0 0 18px; color: inherit; font-size: clamp(2rem, 5vw, 3.8rem); line-height: 1.05; } .wayfarer-presentation-description { font-size: clamp(1rem, 2vw, 1.22rem); line-height: 1.7; } .wayfarer-presentation-actions { width: min(760px, 100%); margin: 32px auto 0; display: flex; justify-content: flex-end; } .wayfarer-presentation-continue { min-width: 150px; padding: 11px 18px; border: 1px solid #f8fafc; border-radius: 8px; background: #f8fafc; color: #15181d; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-presentation-continue:hover { background: #e8edf3; } .wayfarer-presentation-continue:focus-visible { outline: 3px solid #ffffff; outline-offset: 4px; } .wayfarer-presentation-key-hint { margin: 10px 0 0; color: #9ea8b6; font-size: 0.78rem; text-align: right; } body.wayfarer-runtime-presentation { overflow: hidden; } @media (max-width: 620px) { .wayfarer-presentation-screen { padding: 20px 14px; } .wayfarer-presentation-inner { min-height: calc(100vh - 40px); } .wayfarer-presentation-actions { justify-content: stretch; } .wayfarer-presentation-continue { width: 100%; } } /* ------------------------------------------------------------------ COMBINED AUTHOR STATE + REGION WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 ------------------------------------------------------------------ */ .wayfarer-author-state-region { margin-top: 20px; padding-top: 14px; border-top: 1px solid #e4e7ec; } .wayfarer-author-state-region > :first-child { margin-top: 0; } /* ------------------------------------------------------------------ TERMINAL GAME ENDING WAYFARER_GAME_ENDING_SCREEN_V45 ------------------------------------------------------------------ */ .wayfarer-ending-screen { position: fixed; z-index: 11000; inset: 0; overflow-y: auto; padding: clamp(24px, 6vh, 72px) 24px; background: #11151a; color: #f8fafc; } .wayfarer-ending-inner { width: min(1040px, 100%); min-height: calc(100vh - clamp(48px, 12vh, 144px)); margin: 0 auto; display: flex; flex-direction: column; justify-content: center; } .wayfarer-ending-media { width: 100%; margin: 0 0 28px; overflow: hidden; border-radius: 12px; background: #080a0d; } .wayfarer-ending-media img { display: block; width: 100%; max-height: 52vh; object-fit: cover; } .wayfarer-ending-copy { width: min(760px, 100%); margin: 0 auto; } .wayfarer-ending-eyebrow { margin: 0 0 8px; color: #aeb8c5; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .wayfarer-ending-copy h1 { margin: 0 0 18px; color: inherit; font-size: clamp(2rem, 5vw, 3.8rem); line-height: 1.05; } .wayfarer-ending-text { font-size: clamp(1rem, 2vw, 1.22rem); line-height: 1.7; } body.wayfarer-game-ended { overflow: hidden; } ================================================================================ FILE: update-backups/v61-20260925-224153/play/assets/play.js ================================================================================ /* |-------------------------------------------------------------------------- | PLAYER RUNTIME CONTROLLER | WAYFARER_ACTION_SCREEN_V39 | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | Runtime modes currently exposed to the player: | | presentation -> Action -> running navigation -> presentation/Action | | Presentation is persisted by PHP. Automatic tick advancement cannot continue | while a presentation is active. | */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; if (!config) { return; } const body = document.body; const tickValue = document.getElementById( 'wayfarer-tick-value' ); const statusValue = document.getElementById( 'wayfarer-tick-status' ); const message = document.getElementById( 'wayfarer-tick-message' ); const onceButton = document.getElementById( 'wayfarer-tick-once' ); const autoButton = document.getElementById( 'wayfarer-tick-auto' ); const authorPauseButton = document.getElementById( 'wayfarer-tick-pause' ); const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); const continueTravelButton = document.querySelector( '[data-wayfarer-continue-travel]' ); const playerPauseButton = document.querySelector( '[data-wayfarer-pause-travel]' ); /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Action Mode owns one physical card. Inventory, Status, and future * utility/context views temporarily replace the normal action heading/list * inside that card without becoming persisted runtime modes. */ const actionHeading = actionScreen ? actionScreen.querySelector( '.wayfarer-action-heading' ) : null; const actionList = actionScreen ? actionScreen.querySelector( '[data-wayfarer-action-list]' ) : null; const actionSubviewPanels = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ) : []; const actionViewOpeners = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-open-action-view]' ) ) : []; const actionViewClosers = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-close-action-view]' ) ) : []; /* * Persisted narrative presentation. */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); const presentationContinueButton = presentationScreen ? presentationScreen.querySelector( '[data-wayfarer-presentation-continue]' ) : null; let running = false; let requestInFlight = false; let presentationRequestInFlight = false; let storyletRequestInFlight = false; let timeoutId = null; let reloadWhenIdle = false; let presentationActive = Boolean( presentationScreen && !presentationScreen.hidden ); function setMessage( text, isError = false ) { if (!message) { return; } message.textContent = text; message.style.color = isError ? 'red' : ''; } function emitModeEvent() { let mode = 'paused'; if (presentationActive) { mode = 'presentation'; } else if (running) { mode = 'running'; } window.dispatchEvent( new CustomEvent( 'wayfarer:runtime-mode', { detail: { mode: mode } } ) ); /* * Keep the v39 event names available for existing development hooks. */ window.dispatchEvent( new CustomEvent( running ? 'wayfarer:runtime-running' : 'wayfarer:runtime-paused', { detail: { mode: mode } } ) ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Generic in-card view switcher. Future Map / Shop / other views can use * the same data-wayfarer-open-action-view + data-wayfarer-action-view * contract without adding another page-level layout system. */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Resolve available views at the moment navigation occurs instead of * relying on the V48 page-load snapshot. This supports nested Entity * detail screens and future context views added to the same Action card. */ function setActionView(viewName = 'actions') { if (!actionScreen) { return; } const requested = String( viewName || 'actions' ); const showActions = requested === 'actions'; const panels = Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ); let matched = showActions; panels.forEach( function (panel) { const active = panel.dataset .wayfarerActionView === requested; panel.hidden = !active; if (active) { matched = true; } } ); /* * Fail safely if a malformed/removed view name is requested. */ if (!matched) { panels.forEach( function (panel) { panel.hidden = true; } ); if (actionHeading) { actionHeading.hidden = false; } if (actionList) { actionList.hidden = false; } return; } if (actionHeading) { actionHeading.hidden = !showActions; } if (actionList) { actionList.hidden = !showActions; } } function renderControls() { if (statusValue) { statusValue.textContent = presentationActive ? 'Presentation' : ( running ? 'Running' : 'Paused' ); } if (onceButton) { onceButton.disabled = running || requestInFlight || presentationActive; } if (autoButton) { autoButton.disabled = running || requestInFlight || presentationActive; } if (authorPauseButton) { authorPauseButton.disabled = !running; } if (continueTravelButton) { continueTravelButton.disabled = running || requestInFlight || presentationActive; } document .querySelectorAll( '[data-wayfarer-storylet-action-id], ' + '[data-wayfarer-storylet-branch-id]' ) .forEach( function (button) { button.disabled = requestInFlight || presentationRequestInFlight || storyletRequestInFlight || ( button.hasAttribute( 'data-wayfarer-storylet-action-id' ) && (running || presentationActive) ); } ); if (playerPauseButton) { playerPauseButton.disabled = !running; } if (actionScreen) { actionScreen.hidden = running || presentationActive; } if (travelControls) { travelControls.hidden = !running || presentationActive; } if (presentationScreen) { presentationScreen.hidden = !presentationActive; presentationScreen.setAttribute( 'aria-hidden', presentationActive ? 'false' : 'true' ); } body.classList.toggle( 'wayfarer-runtime-running', running ); body.classList.toggle( 'wayfarer-runtime-paused', !running && !presentationActive ); body.classList.toggle( 'wayfarer-runtime-presentation', presentationActive ); } /* * WAYFARER_LIVE_ACTION_CONTEXT_V46_1 * * Action Mode must reflect the current runtime position, not the page's * original server-rendered context. */ function renderPlayerContext(context) { if ( !context || typeof context !== 'object' ) { return; } const heading = document.querySelector( '[data-wayfarer-action-context-name]' ); const parent = document.querySelector( '[data-wayfarer-action-parent-context]' ); if (heading) { const name = String( context.context_name || 'Actions' ).trim(); heading.textContent = name || 'Actions'; } if (parent) { const parentText = String( context.parent_context || '' ).trim(); parent.textContent = parentText; parent.hidden = parentText === ''; } } function displayRuntimeValue(value) { if (typeof value === 'boolean') { return value ? 'True' : 'False'; } if ( value === null || value === undefined ) { return 'null'; } if (typeof value === 'object') { return JSON.stringify( value ); } return String( value ); } /* * WAYFARER_STORYLET_ACTIONS_LIVE_V59 * * Tick/presentation APIs return the currently eligible root Manual * Storylets so Action Mode does not depend on the page-load snapshot. */ function renderStoryletActions(actions) { if (!actionScreen) { return; } const host = actionScreen.querySelector( '[data-wayfarer-storylet-actions]' ); if (!host) { return; } const normalized = Array.isArray(actions) ? actions : []; host.innerHTML = ''; normalized.forEach( function (action) { const id = Number( action && action.id ? action.id : 0 ); if (id <= 0) { return; } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-action-button'; button.dataset.wayfarerStoryletActionId = String(id); button.textContent = String( action.label || action.name || 'Storylet' ); host.appendChild( button ); } ); const empty = actionScreen.querySelector( '[data-wayfarer-action-empty]' ); if (empty) { const canTravel = actionScreen.dataset.wayfarerCanTravel === '1'; empty.hidden = canTravel || host.children.length > 0; } renderControls(); } function renderRuntimeState(state) { if ( !state || typeof state !== 'object' ) { return; } if ( state.navigation && state.navigation.position !== undefined ) { const position = String( state.navigation.position ); const authorPosition = document.getElementById( 'wayfarer-author-position' ); if (authorPosition) { authorPosition.textContent = position; } } const gameTraits = state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : {}; document .querySelectorAll( '[data-wayfarer-game-trait-value]' ) .forEach( function (cell) { const traitId = cell.dataset .wayfarerGameTraitValue; if ( Object.prototype .hasOwnProperty .call( gameTraits, traitId ) ) { cell.textContent = displayRuntimeValue( gameTraits[traitId] ); } } ); document .querySelectorAll( '[data-wayfarer-region-trait-value]' ) .forEach( function (cell) { const regionId = cell.dataset .wayfarerRegionId; const traitId = cell.dataset .wayfarerRegionTraitValue; const traits = state.regions && state.regions[regionId] && state.regions[regionId].traits && typeof state.regions[regionId].traits === 'object' ? state.regions[regionId].traits : null; if ( traits && Object.prototype .hasOwnProperty .call( traits, traitId ) ) { cell.textContent = displayRuntimeValue( traits[traitId] ); } } ); const debug = document.getElementById( 'wayfarer-runtime-debug' ); if (debug) { debug.textContent = JSON.stringify( state, null, 2 ); } } async function requestTick() { if ( requestInFlight || presentationActive ) { return null; } requestInFlight = true; renderControls(); try { const response = await fetch( config.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Tick endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Tick request failed.' ); } if (tickValue) { tickValue.textContent = String( data.tick ); } renderRuntimeState( data.state ); renderPlayerContext( data.context ); renderStoryletActions( data.actions ); setMessage( 'Tick ' + data.tick + ' saved.' ); if (data.pause === true) { /* * Suppress Action Mode during the brief interval before reload. * The server-rendered presentation will exist after reload. */ presentationActive = true; pauseRuntime( data.pause_reason || 'Travel interrupted.', true ); } return data; } catch (error) { pauseRuntime( error instanceof Error ? error.message : 'Tick request failed.' ); setMessage( error instanceof Error ? error.message : 'Tick request failed.', true ); return null; } finally { requestInFlight = false; renderControls(); if (reloadWhenIdle) { reloadWhenIdle = false; window.location.reload(); } } } async function runAutomaticTick() { if ( !running || presentationActive ) { return; } const result = await requestTick(); if ( !running || presentationActive || result === null ) { return; } timeoutId = window.setTimeout( runAutomaticTick, Number( config.intervalMs ) || 1500 ); } function startRuntime() { if ( running || requestInFlight || presentationActive ) { return; } /* * Travel always leaves inspection/context views and returns the card to * its normal Action view for the next pause. */ setActionView( 'actions' ); running = true; setMessage( 'Automatic advancement started.' ); renderControls(); emitModeEvent(); runAutomaticTick(); } function pauseRuntime( reason = 'Automatic advancement paused.', reloadForPresentation = false ) { const wasRunning = running; running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } setMessage( reason ); if (wasRunning) { setActionView( 'actions' ); } renderControls(); if (wasRunning) { emitModeEvent(); } if (reloadForPresentation) { if (requestInFlight) { reloadWhenIdle = true; } else { window.location.reload(); } } } /* * WAYFARER_STORYLET_ACTION_RUNTIME_V59 */ async function requestStorylet( storyletId, entryMode = 'action', parentStoryletId = 0 ) { if ( storyletRequestInFlight || requestInFlight || presentationRequestInFlight ) { return; } storyletId = Number(storyletId || 0); parentStoryletId = Number(parentStoryletId || 0); if (storyletId <= 0) { return; } storyletRequestInFlight = true; renderControls(); try { const response = await fetch( config.storyletEndpoint || 'api/storylet.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, storylet_id: storyletId, entry_mode: entryMode, parent_storylet_id: parentStoryletId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Storylet endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not enter Storylet.' ); } /* * Storylet presentation/Branch markup is server-rendered. Reload * only after the runtime transaction succeeds, so Effects cannot be * repeated by a failed or duplicate browser transition. */ window.location.reload(); } catch (error) { window.alert( error instanceof Error ? error.message : 'Could not enter Storylet.' ); } finally { storyletRequestInFlight = false; renderControls(); } } async function dismissPresentation() { if ( !presentationActive || !presentationScreen || presentationRequestInFlight ) { return; } presentationRequestInFlight = true; if (presentationContinueButton) { presentationContinueButton.disabled = true; } const presentationType = presentationScreen.dataset .wayfarerPresentationType || ''; const presentationId = Number( presentationScreen.dataset .wayfarerPresentationId || 0 ); try { const response = await fetch( config.presentationEndpoint || 'api/presentation.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, type: presentationType, id: presentationId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Presentation endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not continue.' ); } presentationActive = false; setActionView( 'actions' ); renderRuntimeState( data.state ); renderPlayerContext( data.context ); renderStoryletActions( data.actions ); renderControls(); emitModeEvent(); /* * Place keyboard focus on the first available Action so the * transition works cleanly for keyboard players too. */ const firstAction = actionScreen ? actionScreen.querySelector( 'button:not([disabled]), a[href]' ) : null; if ( firstAction && typeof firstAction.focus === 'function' ) { firstAction.focus(); } } catch (error) { if (presentationContinueButton) { presentationContinueButton.disabled = false; } window.alert( error instanceof Error ? error.message : 'Could not continue.' ); } finally { presentationRequestInFlight = false; if ( presentationContinueButton && presentationActive ) { presentationContinueButton.disabled = false; } } } if (onceButton) { onceButton.addEventListener( 'click', async function () { if ( running || presentationActive ) { return; } setMessage(''); await requestTick(); } ); } if (autoButton) { autoButton.addEventListener( 'click', startRuntime ); } if (authorPauseButton) { authorPauseButton.addEventListener( 'click', function () { pauseRuntime( 'Automatic advancement paused.' ); } ); } if (continueTravelButton) { continueTravelButton.addEventListener( 'click', startRuntime ); } if (playerPauseButton) { playerPauseButton.addEventListener( 'click', function () { /* * WAYFARER_SMOOTH_MANUAL_PAUSE_V46_2 * * v46.1 keeps Action context current in-place, so manual Pause * no longer needs a full page reload. */ pauseRuntime( 'Travel paused.' ); } ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Ensure V50 detail panels physically belong to the Action card. This is * harmless when the PHP view already placed them correctly, and repairs * any earlier V50 markup that landed just outside the host. */ if (actionScreen) { document .querySelectorAll( '.wayfarer-entity-detail-view' ) .forEach( function (panel) { if ( !actionScreen.contains( panel ) ) { actionScreen.appendChild( panel ); } } ); /* * Delegate navigation from the Action card itself. Any current or * future control using the generic data attributes automatically * participates in the same router. */ actionScreen.addEventListener( 'click', function (event) { const storyletAction = event.target.closest( '[data-wayfarer-storylet-action-id]' ); if ( storyletAction && actionScreen.contains( storyletAction ) ) { if ( running || presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( storyletAction.dataset .wayfarerStoryletActionId, 'action', 0 ); return; } const opener = event.target.closest( '[data-wayfarer-open-action-view]' ); if ( opener && actionScreen.contains( opener ) ) { if ( running || presentationActive ) { return; } event.preventDefault(); setActionView( opener.dataset .wayfarerOpenActionView || 'actions' ); return; } const closer = event.target.closest( '[data-wayfarer-close-action-view]' ); if ( !closer || !actionScreen.contains( closer ) ) { return; } event.preventDefault(); const returnView = closer.dataset .wayfarerReturnActionView || 'actions'; setActionView( returnView ); let focusTarget = null; if (returnView === 'actions') { focusTarget = actionList ? actionList.querySelector( 'button:not([disabled]), a[href]' ) : null; } else { const parentPanel = actionScreen.querySelector( '[data-wayfarer-action-view="' + returnView + '"]' ); focusTarget = parentPanel ? parentPanel.querySelector( '[data-wayfarer-open-action-view], button:not([disabled]), a[href]' ) : null; } if ( focusTarget && typeof focusTarget.focus === 'function' ) { focusTarget.focus(); } } ); } /* WAYFARER_STORYLET_BRANCH_SELECTION_V59 */ document.addEventListener( 'click', function (event) { const branch = event.target.closest( '[data-wayfarer-storylet-branch-id]' ); if (!branch) { return; } if ( !presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( branch.dataset .wayfarerStoryletBranchId, 'branch', branch.dataset .wayfarerStoryletParentId ); } ); if (presentationContinueButton) { presentationContinueButton.addEventListener( 'click', dismissPresentation ); } document.addEventListener( 'keydown', function (event) { if ( !presentationActive || presentationRequestInFlight || storyletRequestInFlight || !presentationContinueButton ) { return; } const target = event.target; if ( target && target.matches && target.matches( 'input, textarea, select, button, a[href], [contenteditable="true"]' ) ) { return; } if ( event.key !== 'Enter' && event.key !== ' ' ) { return; } event.preventDefault(); dismissPresentation(); } ); window.addEventListener( 'pagehide', function () { running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } } ); window.wayfarerRuntimeController = { start: startRuntime, pause: pauseRuntime, dismissPresentation: dismissPresentation, enterStorylet: requestStorylet, isRunning: function () { return running; }, mode: function () { if (presentationActive) { return 'presentation'; } return running ? 'running' : 'paused'; } }; setActionView( 'actions' ); renderControls(); emitModeEvent(); })(); /* |-------------------------------------------------------------------------- | WAYFARER PLAY SHELL v20 |-------------------------------------------------------------------------- | | Reorganizes existing server-rendered nodes into the viewport shell. | Existing nodes are MOVED, not cloned/replaced, so the tick-control | event listeners installed above remain intact. | */ (() => { 'use strict'; if ( document.getElementById( 'wayfarer-play-shell' ) ) { return; } function exactHeading( level, text ) { const headings = document.querySelectorAll( level ); for (const heading of headings) { if ( heading.textContent .replace(/\s+/g, ' ') .trim() === text ) { return heading; } } return null; } function moveRange( start, stopExclusive, destination ) { if ( !start || !destination ) { return; } let node = start; while ( node && node !== stopExclusive ) { const next = node.nextSibling; destination.appendChild( node ); node = next; } } const body = document.body; /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * PHP decides whether Author Mode is permitted. JavaScript only shapes the * interface around that server-side decision. */ const canUseAuthorMode = body.dataset.wayfarerAuthorMode === '1'; body.classList.add( 'wayfarer-play-page' ); const shell = document.createElement( 'div' ); shell.id = 'wayfarer-play-shell'; shell.className = 'wayfarer-play-shell'; const header = document.createElement( 'header' ); header.className = 'wayfarer-play-shell-header'; const title = document.createElement( 'div' ); title.className = 'wayfarer-play-title'; const actions = document.createElement( 'div' ); actions.className = 'wayfarer-shell-actions'; const toggleAuthor = document.createElement( 'button' ); toggleAuthor.type = 'button'; toggleAuthor.className = 'wayfarer-shell-button'; if (canUseAuthorMode) { actions.appendChild( toggleAuthor ); } header.appendChild( title ); if (canUseAuthorMode) { header.appendChild( actions ); } const main = document.createElement( 'main' ); main.className = 'wayfarer-play-main'; const playerPane = document.createElement( 'section' ); playerPane.className = 'wayfarer-player-pane'; const authorPane = document.createElement( 'aside' ); authorPane.className = 'wayfarer-author-pane'; const authorHeader = document.createElement( 'div' ); authorHeader.className = 'wayfarer-author-header'; const authorToolbar = document.createElement( 'div' ); authorToolbar.className = 'wayfarer-author-toolbar'; const tabBar = document.createElement( 'div' ); tabBar.className = 'wayfarer-author-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const authorContent = document.createElement( 'div' ); authorContent.className = 'wayfarer-author-content'; authorPane.appendChild( authorHeader ); authorPane.appendChild( authorToolbar ); authorPane.appendChild( tabBar ); authorPane.appendChild( authorContent ); main.appendChild( playerPane ); if (canUseAuthorMode) { main.appendChild( authorPane ); } else { /* * Keep the player workspace full-width when Author Mode is unavailable. * No Author pane was rendered by PHP and none is attached here. */ shell.classList.add( 'author-collapsed' ); } shell.appendChild( header ); shell.appendChild( main ); body.insertBefore( shell, body.firstChild ); // ------------------------------------------------------------ // Top title/player identity // ------------------------------------------------------------ const pageTitle = Array.from( body.querySelectorAll( ':scope > h1' ) )[0]; if (pageTitle) { const playerIdentity = pageTitle.nextElementSibling; title.appendChild( pageTitle ); if ( playerIdentity && playerIdentity.tagName === 'P' ) { title.appendChild( playerIdentity ); } } // ------------------------------------------------------------ // Player content // ------------------------------------------------------------ /* * WAYFARER_PLAYER_START_SCREEN_V36 * * Before runtime state exists, the start screen owns the full player * workspace and Author Mode is intentionally hidden. */ const startScreen = document.querySelector( '[data-wayfarer-start-screen]' ); if (startScreen) { shell.classList.add( 'is-starting' ); playerPane.appendChild( startScreen ); } const scene = document.getElementById( 'wayfarer-player-scene' ); const gameStateHeading = exactHeading( 'h2', 'Game State' ) || exactHeading( 'h2', 'Game Not Initialized' ); if (gameStateHeading) { const meta = document.createElement( 'div' ); meta.className = 'wayfarer-player-meta'; /* * Move from Game State through the node immediately before the * player scene. This includes initialization messaging/forms but * stops before the actual scene. */ moveRange( gameStateHeading, scene, meta ); playerPane.appendChild( meta ); } if (scene) { playerPane.appendChild( scene ); } /* * WAYFARER_ACTION_SCREEN_V39 * * Action Mode is player-facing and exists independently of Author Mode. */ const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); if (actionScreen) { playerPane.appendChild( actionScreen ); } /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); if (presentationScreen) { playerPane.appendChild( presentationScreen ); } const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); if (travelControls) { playerPane.appendChild( travelControls ); } // ------------------------------------------------------------ // Author header // ------------------------------------------------------------ const authorHeading = exactHeading( 'h2', 'Author Mode' ); if (authorHeading) { const builderLinkBlock = authorHeading.nextElementSibling; authorHeader.appendChild( authorHeading ); if ( builderLinkBlock && builderLinkBlock.tagName === 'P' ) { authorHeader.appendChild( builderLinkBlock ); } } // ------------------------------------------------------------ // Runtime controls // ------------------------------------------------------------ const tickPanel = document.getElementById( 'wayfarer-tick-test' ); if (tickPanel) { authorToolbar.appendChild( tickPanel ); } // ------------------------------------------------------------ // Author tabs // ------------------------------------------------------------ /* WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 */ const panelDefinitions = [ { key: 'state', label: 'State' }, { key: 'debug', label: 'Debug' } ]; function activateTab(key) { document .querySelectorAll( '.wayfarer-author-tab' ) .forEach( function (button) { const active = button.dataset .wayfarerAuthorTab === key; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); authorContent .querySelectorAll( '.wayfarer-author-tab-panel' ) .forEach( function (panel) { panel.classList.toggle( 'is-active', panel.dataset .wayfarerAuthorPanel === key ); } ); try { window.localStorage.setItem( 'wayfarerAuthorTab', key ); } catch (error) { // Storage is optional. } } for (const definition of panelDefinitions) { const panel = document.querySelector( '[data-wayfarer-author-panel="' + definition.key + '"]' ); if (!panel) { continue; } authorContent.appendChild( panel ); const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-author-tab'; button.dataset.wayfarerAuthorTab = definition.key; button.setAttribute( 'role', 'tab' ); button.textContent = definition.label; button.addEventListener( 'click', function () { activateTab( definition.key ); } ); tabBar.appendChild( button ); } let initialTab = 'state'; try { let savedTab = window.localStorage.getItem( 'wayfarerAuthorTab' ); if ( savedTab === 'region' ) { savedTab = 'state'; window.localStorage.setItem( 'wayfarerAuthorTab', 'state' ); } if ( savedTab && authorContent.querySelector( '[data-wayfarer-author-panel="' + savedTab + '"]' ) ) { initialTab = savedTab; } } catch (error) { // Storage is optional. } activateTab( initialTab ); // ------------------------------------------------------------ // Hide / Show Author Mode // WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 // ------------------------------------------------------------ if (canUseAuthorMode) { function renderAuthorToggle() { const collapsed = shell.classList.contains( 'author-collapsed' ); toggleAuthor.textContent = collapsed ? 'Show Author' : 'Hide Author'; toggleAuthor.setAttribute( 'aria-expanded', collapsed ? 'false' : 'true' ); } toggleAuthor.addEventListener( 'click', function () { shell.classList.toggle( 'author-collapsed' ); try { window.localStorage.setItem( 'wayfarerAuthorCollapsed', shell.classList.contains( 'author-collapsed' ) ? '1' : '0' ); } catch (error) { // Storage is optional. } renderAuthorToggle(); } ); try { if ( window.localStorage.getItem( 'wayfarerAuthorCollapsed' ) === '1' ) { shell.classList.add( 'author-collapsed' ); } } catch (error) { // Storage is optional. } renderAuthorToggle(); } else { /* * Player role is not merely a hidden Author interface. Keep the shell * permanently in player-only layout. */ shell.classList.add( 'author-collapsed' ); } // ------------------------------------------------------------ // Remove otherwise-empty top-level HRs left by the old document flow. // ------------------------------------------------------------ body .querySelectorAll( ':scope > hr' ) .forEach( function (rule) { rule.remove(); } ); })(); ================================================================================ FILE: update-backups/v61-20260925-224153/play/includes/action-screen.php ================================================================================ 0) { $actionRegionStmt = $pdo->prepare( " SELECT id, name, navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $actionRegionStmt->execute([ $actionRegionId, (int)$gameId, ]); $actionRegion = $actionRegionStmt->fetch( PDO::FETCH_ASSOC ); if ($actionRegion) { $actionRegionNavigationType = (string)( $actionRegion['navigation_type'] ?? '' ); $actionContextName = trim( (string)( $actionRegion['name'] ?? '' ) ) ?: 'Actions'; } } $actionLocation = wayfarerRuntimeFindCurrentLocation( $pdo, (int)$gameId, $state ); if ($actionLocation) { $locationName = trim( (string)( $actionLocation['name'] ?? '' ) ); if ($locationName !== '') { $actionContextName = $locationName; } } $actionCanTravel = strtolower( trim( $actionRegionNavigationType ) ) === 'linear'; /* WAYFARER_STORYLET_ACTION_SCREEN_V59 */ $actionStorylets = wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$gameId, $state ); /* * WAYFARER_ENTITY_PRESENTATION_V49 * * The V48 card switcher remains generic. V49 supplies the Inventory / Status * view models from canonical runtime Entity state. */ $actionEntityPresentation = wayfarerRuntimeBuildEntityPresentation( $pdo, (int)$gameId, $state, is_array($gameConfig ?? null) ? $gameConfig : [] ); $actionInventory = $actionEntityPresentation['inventory'] ?? []; $actionParty = $actionEntityPresentation['party'] ?? []; /* WAYFARER_PLAYER_TRAITS_STATUS_V52 */ $actionPlayerPresentation = wayfarerRuntimeBuildPlayerPresentation( is_array($gameConfig ?? null) ? $gameConfig : [], $state ); $actionPlayers = $actionPlayerPresentation['players'] ?? []; $actionGameStatusTraits = $actionPlayerPresentation['game_status_traits'] ?? []; ?>

>

What would you like to do?

No actions are currently available.

================================================================================ FILE: update-backups/v61-20260925-224153/play/includes/runtime.php ================================================================================ prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? ORDER BY id " ); $stmt->execute([ $gameId ]); $regions = $stmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($regions as $region) { $regionId = (int)( $region['id'] ?? 0 ); if ($regionId <= 0) { continue; } $regionKey = (string)$regionId; $config = wayfarerRuntimeDecodeConfig( $region['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset( $runtimeState['regions'][$regionKey] ) || !is_array( $runtimeState['regions'][$regionKey] ) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset( $runtimeState['regions'][$regionKey]['traits'] ) || !is_array( $runtimeState['regions'][$regionKey]['traits'] ) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['regions'][$regionKey]['traits'] ) ) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $initialValue; } } } /* |-------------------------------------------------------------------------- | Persisted state for every Location | WAYFARER_ALL_LOCATION_STATE_V56 |-------------------------------------------------------------------------- | | Locations can own Traits, so universal Conditions/Effects need persistent | per-playthrough Location Trait state just like Regions. | */ function wayfarerRuntimeEnsureAllLocationTraitState( PDO $pdo, int $gameId, array &$runtimeState ): void { if ($gameId <= 0) { return; } if ( !isset($runtimeState['locations']) || !is_array($runtimeState['locations']) ) { $runtimeState['locations'] = []; } $stmt = $pdo->prepare( " SELECT l.id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $location ) { $locationId = (int)($location['id'] ?? 0); if ($locationId <= 0) { continue; } $locationKey = (string)$locationId; $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $initialTraits = wayfarerRuntimeInitialTraitState( $config ); if ( !isset($runtimeState['locations'][$locationKey]) || !is_array($runtimeState['locations'][$locationKey]) ) { $runtimeState['locations'][$locationKey] = []; } if ( !isset($runtimeState['locations'][$locationKey]['traits']) || !is_array($runtimeState['locations'][$locationKey]['traits']) ) { $runtimeState['locations'][$locationKey]['traits'] = []; } foreach ( $initialTraits as $traitId => $initialValue ) { if ( array_key_exists( $traitId, $runtimeState['locations'][$locationKey]['traits'] ) ) { continue; } $runtimeState['locations'][$locationKey]['traits'][$traitId] = $initialValue; } } } function wayfarerRuntimeLoadStartingRegion( PDO $pdo, int $gameId, ?array $gameConfig = null ): ?array { if ($gameId <= 0) { return null; } if ($gameConfig === null) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { return null; } $gameConfig = wayfarerRuntimeDecodeConfig( $rawConfig ); } $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT * FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $startingRegionId, $gameId, ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); return $region ?: null; } function wayfarerRuntimeInitializeGame( PDO $pdo, int $gameUserId, array $setupInput = [] ): array { if ($gameUserId <= 0) { throw new RuntimeException( 'A valid game-user ID is required to initialize the game.' ); } try { $pdo->beginTransaction(); /* * Lock the playthrough row so two initialize requests cannot create * competing starting states. */ $stmt = $pdo->prepare( " SELECT gu.id, gu.game_id, gu.user_id, gu.role, gu.state, g.config AS game_config, g.player_slots AS player_slots FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id WHERE gu.id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } /* * Initialization is intentionally one-way. To restart a development * game, clear wf_game_users.state to NULL first. */ if ( $row['state'] !== null && $row['state'] !== '' ) { throw new RuntimeException( 'This game-user already has runtime state.' ); } $gameId = (int)$row['game_id']; $gameConfig = wayfarerRuntimeDecodeConfig( $row['game_config'] ?? null ); $startingRegionId = (int)( $gameConfig['starting_region_id'] ?? 0 ); if ($startingRegionId <= 0) { throw new RuntimeException( 'This Game does not have a Starting Region configured.' ); } $startingRegion = wayfarerRuntimeLoadStartingRegion( $pdo, $gameId, $gameConfig ); if (!$startingRegion) { throw new RuntimeException( 'The configured Starting Region does not exist for this Game.' ); } $regionConfig = wayfarerRuntimeDecodeConfig( $startingRegion['config'] ?? null ); $gameTraits = wayfarerRuntimeInitialTraitState( $gameConfig ); $regionTraits = wayfarerRuntimeInitialTraitState( $regionConfig ); $navigation = [ 'region_id' => (int)$startingRegion['id'], ]; /* * Linear travel currently begins at coordinate zero. * Other navigation modes will get their own authored start-state * rules when those modes are implemented. */ if ( strtolower( trim( (string)( $startingRegion['navigation_type'] ?? '' ) ) ) === 'linear' ) { $navigation['position'] = 0; } $regionKey = (string)$startingRegion['id']; $state = [ /* * WAYFARER_RUNTIME_SCHEMA_V55 * Version the persisted playthrough contract explicitly. */ 'schema_version' => 1, 'game' => [ 'traits' => $gameTraits, ], 'navigation' => $navigation, 'regions' => [ $regionKey => [ 'traits' => $regionTraits, ], ], 'runtime' => [ 'tick' => 0, ], ]; /* * WAYFARER_ALL_REGION_STATE_V46 * * Global conditions can inspect Region state before that Region has * ever been visited. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $state ); /* WAYFARER_INITIALIZE_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_V49 * * Items and Characters receive canonical per-playthrough state before * any starting setup effects are applied. */ wayfarerRuntimeEnsureEntityState( $pdo, $gameId, $state ); /* * WAYFARER_APPLY_STARTING_CHOICES_V36 * * Validate all player-entered setup data before persisting anything. * Then apply the selected options' starting effects to the canonical * initial runtime state. */ $preparedSetup = wayfarerRuntimePrepareStartingSetup( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $setupInput ); /* * WAYFARER_PLAYER_TRAITS_RUNTIME_V52 * * Persist the validated player-character names before creating the * per-player Trait state. Each slot receives independent values. */ $state['setup'] = [ 'player_names' => $preparedSetup['player_names'], 'choices' => $preparedSetup['choices'], ]; wayfarerRuntimeEnsureGameTraitState( $gameConfig, $state ); wayfarerRuntimeEnsurePlayerState( $gameConfig, (int)( $row['player_slots'] ?? 0 ), $state ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $startingEffectReports = []; foreach ( $preparedSetup['effects'] as $startingEffect ) { /* WAYFARER_STARTING_CHOICE_RANDOM_RUNTIME_V58 */ $resolvedStartingValue = wayfarerRuntimeRollEffectValue( $startingEffect ); if (!$resolvedStartingValue['valid']) { throw new RuntimeException( 'Unable to resolve a Starting Choice effect value: ' . $resolvedStartingValue['error'] ); } $concreteStartingEffect = $startingEffect; $concreteStartingEffect['value'] = $resolvedStartingValue['value']; $report = wayfarerApplyRuntimeEffect( $state, $concreteStartingEffect, (int)$startingRegion['id'], $traitDefinitions ); if ( !empty($report['error']) ) { throw new RuntimeException( 'Unable to apply a Starting Choice effect: ' . $report['error'] ); } $report['value_mode'] = $resolvedStartingValue['mode']; $report['rolled_value'] = $resolvedStartingValue['value']; if ($resolvedStartingValue['mode'] === 'random') { $report['random_min'] = $resolvedStartingValue['min']; $report['random_max'] = $resolvedStartingValue['max']; } $startingEffectReports[] = $report; } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * * Starting-effect reports are intentionally not persisted. The effects * themselves have already mutated canonical state. */ /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 * * A game that begins exactly on an active Location should present that * Location before ordinary Action Mode. */ $startingLocation = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $state ); if ($startingLocation) { /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Starting on a Location is a real Location trigger. Apply its * one-time Effects before opening Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, $gameId, $startingLocation, $state ); $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$startingLocation['id'], 'phase' => 'presentation', 'trigger' => 'initial', 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /* WAYFARER_GAME_ENDINGS_INITIAL_V45 */ wayfarerRuntimeEvaluateGameEndings( $pdo, $gameId, $state ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => $gameId, 'starting_region_id' => (int)$startingRegion['id'], 'state' => $state, ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Resolve a game-user record for the tick prototype. * * Preferred: * explicit wf_game_users.id * * Temporary development fallback: * first wf_game_users record. * * The fallback exists only because Wayfarer has not yet wired the play * page to authentication / game selection. Once that exists, remove the * fallback and always pass the active game_user_id explicitly. */ function wayfarerRuntimeResolveGameUserId( PDO $pdo, ?int $requestedId = null ): int { if ($requestedId !== null && $requestedId > 0) { $stmt = $pdo->prepare( " SELECT id FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $requestedId ]); $id = $stmt->fetchColumn(); if ($id !== false) { return (int)$id; } throw new RuntimeException( 'The requested game-user record does not exist.' ); } $id = $pdo ->query( " SELECT id FROM wf_game_users ORDER BY id LIMIT 1 " ) ->fetchColumn(); if ($id === false) { throw new RuntimeException( 'No wf_game_users record exists.' ); } return (int)$id; } /** * Load one game-user runtime state. */ function wayfarerRuntimeLoad( PDO $pdo, int $gameUserId ): array { $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'state' => $state, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; } /** * Advance exactly one tick. * * This is intentionally the entire v14 game engine: * * state.runtime.tick += 1 * * The transaction and row lock are already in place so later work can add * movement/effects without changing the request architecture. */ /* |-------------------------------------------------------------------------- | Linear Location crossing | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | A tick may move farther than the distance to the next authored Location. | This resolver finds the FIRST active Location crossed in the direction of | travel. The movement is then clamped to that exact authored coordinate. | | Examples: | 45 -> 55, Location 50 => stop at 50 | 55 -> 45, Location 50 => stop at 50 | | The starting coordinate is intentionally exclusive so leaving a Location | does not immediately trigger that same Location again. | */ /* |-------------------------------------------------------------------------- | Location Conditions | WAYFARER_LOCATION_CONDITIONS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeLocationConditionsPass( PDO $pdo, int $gameId, array $location, array &$runtimeState ): bool { $config = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); $conditions = $config['conditions'] ?? null; $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; return wayfarerConditionsPass( $conditions, $resolver ); } /* |-------------------------------------------------------------------------- | Game Endings | WAYFARER_GAME_ENDINGS_RUNTIME_V45 |-------------------------------------------------------------------------- */ function wayfarerRuntimeEvaluateGameEndings( PDO $pdo, int $gameId, array &$runtimeState ): ?array { /* * WAYFARER_ALL_REGION_STATE_ENDINGS_V46 */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $runtimeState ); /* WAYFARER_RECOVER_LOCATION_STATE_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $runtimeState ); if ( isset( $runtimeState['runtime']['ending'] ) && is_array( $runtimeState['runtime']['ending'] ) ) { return $runtimeState['runtime']['ending']; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); $config = wayfarerRuntimeDecodeConfig( $raw === false ? null : $raw ); $endings = $config['endings'] ?? []; if (!is_array($endings)) { return null; } $ordered = []; foreach ( array_values($endings) as $index => $ending ) { if (!is_array($ending)) { continue; } $ending['_runtime_order'] = $index; $ordered[] = $ending; } usort( $ordered, function ( array $a, array $b ): int { $priorityCompare = (int)( $b['priority'] ?? 0 ) <=> (int)( $a['priority'] ?? 0 ); if ($priorityCompare !== 0) { return $priorityCompare; } return (int)( $a['_runtime_order'] ?? 0 ) <=> (int)( $b['_runtime_order'] ?? 0 ); } ); $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; foreach ($ordered as $ending) { if ( !wayfarerConditionsPass( $ending['conditions'] ?? null, $resolver ) ) { continue; } $terminal = [ 'id' => (string)( $ending['id'] ?? '' ), 'title' => (string)( $ending['title'] ?? 'The End' ), 'text' => (string)( $ending['text'] ?? '' ), 'image_media_id' => (int)( $ending['image_media_id'] ?? 0 ), 'priority' => (int)( $ending['priority'] ?? 0 ), 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), ]; if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['ending'] = $terminal; unset( $runtimeState['runtime']['presentation'] ); return $terminal; } return null; } function wayfarerRuntimeFindCrossedLocation( PDO $pdo, int $gameId, int $regionId, $fromPosition, $toPosition, array &$runtimeState ): ?array { /* * WAYFARER_CROSSED_LOCATION_STATE_FIX_V46_1 * * Location Conditions are evaluated against the live runtime state. */ if ( $gameId <= 0 || $regionId <= 0 || !is_numeric($fromPosition) || !is_numeric($toPosition) ) { return null; } $from = (float)$fromPosition; $to = (float)$toPosition; if ($from === $to) { return null; } /* * Crossing interception currently applies only to Linear Regions. */ $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); $forward = $to > $from; $candidate = null; $candidatePosition = null; foreach ($locations as $location) { $config = wayfarerRuntimeDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $locationPosition = (float)$config['position']; /* * The starting point is exclusive; the destination is inclusive. */ $crossed = $forward ? ( $locationPosition > $from && $locationPosition <= $to ) : ( $locationPosition < $from && $locationPosition >= $to ); if (!$crossed) { continue; } $location['_runtime_config'] = $config; if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } if ($candidate === null) { $candidate = $location; $candidatePosition = $locationPosition; } else { $nearer = $forward ? ( $locationPosition < $candidatePosition ) : ( $locationPosition > $candidatePosition ); $samePositionLowerId = $locationPosition === $candidatePosition && (int)$location['id'] < (int)$candidate['id']; if ( $nearer || $samePositionLowerId ) { $candidate = $location; $candidatePosition = $locationPosition; } } } if ($candidate === null) { return null; } $candidate['_runtime_position'] = wayfarerNormalizeRuntimeNumber( (float)$candidatePosition ); $candidate['_runtime_config'] = wayfarerRuntimeDecodeConfig( $candidate['config'] ?? null ); return $candidate; } /* |-------------------------------------------------------------------------- | Player spatial context |-------------------------------------------------------------------------- | WAYFARER_PLAYER_CONTEXT_V46_1 | | This is the canonical player-facing spatial label for Action Mode: | | exact eligible Location -> Location name + Region parent context | otherwise -> Region name | | It is returned by runtime APIs so the browser does not keep displaying the | Location name from the page's original server render after movement. | */ function wayfarerRuntimePlayerContext( PDO $pdo, int $gameId, array $runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionName = ''; if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $rawRegionName = $stmt->fetchColumn(); if ($rawRegionName !== false) { $regionName = trim( (string)$rawRegionName ); } } $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); $locationId = 0; $locationName = ''; if ($location) { $locationId = (int)( $location['id'] ?? 0 ); $locationName = trim( (string)( $location['name'] ?? '' ) ); } $contextName = $locationName !== '' ? $locationName : ( $regionName !== '' ? $regionName : 'Actions' ); return [ 'region_id' => $regionId, 'region_name' => $regionName, 'location_id' => $locationId, 'location_name' => $locationName, 'context_name' => $contextName, 'parent_context' => $locationName !== '' ? $regionName : '', ]; } /* |-------------------------------------------------------------------------- | Presentation dismissal |-------------------------------------------------------------------------- | | Presentation is persisted runtime state. This function is intentionally | generic even though v40 renders only Location presentations; Storylets can | use the same mechanism later. | */ function wayfarerRuntimeDismissPresentation( PDO $pdo, int $gameUserId, string $expectedType = '', int $expectedId = 0 ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); $presentation = $state['runtime']['presentation'] ?? null; if ( !is_array($presentation) || empty($presentation['type']) || empty($presentation['id']) ) { throw new RuntimeException( 'There is no active presentation to continue.' ); } if ( $expectedType !== '' && (string)$presentation['type'] !== $expectedType ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } if ( $expectedId > 0 && (int)$presentation['id'] !== $expectedId ) { throw new RuntimeException( 'The active presentation changed before it could be dismissed.' ); } /* WAYFARER_STORYLET_BRANCH_DISMISS_GUARD_V59 */ if ( (string)($presentation['type'] ?? '') === 'storylet' ) { $eligibleBranches = wayfarerRuntimeEligibleStoryletBranches( $pdo, (int)$row['game_id'], (int)($presentation['id'] ?? 0), $state ); if ($eligibleBranches) { throw new RuntimeException( 'Choose an available Storylet Branch before continuing.' ); } } $state['runtime']['last_presentation'] = $presentation; unset( $state['runtime']['presentation'] ); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId, ]); $pdo->commit(); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'dismissed' => $presentation, /* WAYFARER_STORYLET_ACTIONS_AFTER_PRESENTATION_V59 */ 'actions' => wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$row['game_id'], $state ), ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } /** * Advance exactly one tick. * * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ function wayfarerRuntimeAdvanceTick( PDO $pdo, int $gameUserId ): array { try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, user_id, role, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } /* * WAYFARER_GAME_ENDINGS_TERMINAL_V45 * * Endings are terminal runtime state. */ if ( isset( $state['runtime']['ending'] ) && is_array( $state['runtime']['ending'] ) ) { throw new RuntimeException( 'This game has ended.' ); } /* * Narrative presentation is a true runtime pause, not merely a hidden * browser control. Even Author Mode cannot advance ticks through it. */ if ( isset( $state['runtime']['presentation'] ) && is_array( $state['runtime']['presentation'] ) ) { throw new RuntimeException( 'Continue the current presentation before advancing time.' ); } /* * WAYFARER_ALL_REGION_STATE_TICK_V46 * * Recovery path for playthroughs initialized before v46, and for * Regions/Traits authored after a playthrough began. */ wayfarerRuntimeEnsureAllRegionTraitState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_LOCATION_STATE_TICK_RECOVERY_V56 */ wayfarerRuntimeEnsureAllLocationTraitState( $pdo, (int)$row['game_id'], $state ); /* * WAYFARER_ENTITY_RUNTIME_STATE_TICK_V49 * * Recovery path for playthroughs created before V49 and for Entities * or Traits authored after play began. */ wayfarerRuntimeEnsureEntityState( $pdo, (int)$row['game_id'], $state ); /* WAYFARER_PLAYER_TRAITS_TICK_RECOVERY_V52 */ wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$row['game_id'], $state ); $regionIdBeforeTick = (int)( $state['navigation']['region_id'] ?? 0 ); $positionBeforeTick = $state['navigation']['position'] ?? null; $currentTick = (int)( $state['runtime']['tick'] ?? 0 ); $state['runtime']['tick'] = $currentTick + 1; /* * Global Game Tick Effects run first. */ wayfarerApplyGameTickEffects( $pdo, (int)$row['game_id'], $state ); /* * Current Region Tick Effects then perform Region-level movement and * state changes. */ wayfarerApplyCurrentRegionTickEffects( $pdo, $state ); /* * Resolve whether the completed movement would have crossed an active * Location. If so, stop at the nearest crossed Location rather than * accepting the overshot position. */ $regionIdAfterMovement = (int)( $state['navigation']['region_id'] ?? 0 ); $positionAfterMovement = $state['navigation']['position'] ?? null; $crossedLocation = null; /* * We only interpret movement as a continuous path while the player * remains in the same Region. Region transitions will later get their * own explicit navigation semantics. */ if ( $regionIdBeforeTick > 0 && $regionIdBeforeTick === $regionIdAfterMovement ) { $crossedLocation = wayfarerRuntimeFindCrossedLocation( $pdo, (int)$row['game_id'], $regionIdAfterMovement, $positionBeforeTick, $positionAfterMovement, $state ); } $arrivalPosition = null; if ($crossedLocation) { $arrivalPosition = $crossedLocation[ '_runtime_position' ]; $state['navigation']['position'] = $arrivalPosition; /* * WAYFARER_LOCATION_ONE_TIME_EFFECT_TRIGGER_V42 * * Event Effects execute once at the trigger boundary. They run * before the Location's Tick Effects and before Presentation Mode. */ wayfarerApplyLocationEffects( $pdo, (int)$row['game_id'], $crossedLocation, $state ); } /* * WAYFARER_LOCATION_EFFECTS_ONLY_RUNTIME_V43 * * Locations no longer own Tick Effects. Game and Region Tick Effects * have already advanced the simulation; a crossed Location contributes * its one-time Effects and then interrupts with Presentation Mode. */ if ($crossedLocation) { $state['navigation']['position'] = $arrivalPosition; $state['runtime']['presentation'] = [ 'type' => 'location', 'id' => (int)$crossedLocation['id'], 'phase' => 'presentation', 'trigger' => 'arrival', 'tick' => (int)$state['runtime']['tick'], ]; $state['runtime']['last_location_arrival'] = [ 'location_id' => (int)$crossedLocation['id'], 'region_id' => $regionIdAfterMovement, 'position' => $arrivalPosition, 'tick' => (int)$state['runtime']['tick'], ]; } /* WAYFARER_GAME_ENDINGS_AFTER_TICK_V45 */ $ending = wayfarerRuntimeEvaluateGameEndings( $pdo, (int)$row['game_id'], $state ); $state['runtime']['last_tick_at'] = gmdate('c'); $encodedState = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encodedState, $gameUserId ]); $pdo->commit(); $presentation = $state['runtime']['presentation'] ?? null; $pausedForPresentation = is_array($presentation) && !empty($presentation['type']) && !empty($presentation['id']); $ending = $state['runtime']['ending'] ?? null; $ended = is_array($ending) && !empty($ending['id']); return [ 'game_user_id' => (int)$row['id'], 'game_id' => (int)$row['game_id'], 'user_id' => (int)$row['user_id'], 'role' => (string)$row['role'], 'tick' => (int)$state['runtime']['tick'], 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, (int)$row['game_id'], $state ), 'pause' => $pausedForPresentation || $ended, 'pause_reason' => $ended ? 'Game ended.' : ( $pausedForPresentation ? 'Location reached.' : '' ), 'presentation' => $pausedForPresentation ? $presentation : null, 'ending' => $ended ? $ending : null, /* WAYFARER_STORYLET_ACTIONS_TICK_API_V59 */ 'actions' => !$pausedForPresentation && !$ended ? wayfarerRuntimeEligibleRootManualStorylets( $pdo, (int)$row['game_id'], $state ) : [], ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: update-backups/v61-20260925-224153/play/index.php ================================================================================ prepare(" SELECT g.id AS game_id, g.name AS game_name, g.slug AS game_slug, g.instructions AS game_instructions, g.player_slots AS player_slots, g.config AS game_config, gu.id AS game_user_id, gu.role AS game_user_role, gu.state, u.username, u.display_name FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id INNER JOIN wf_users u ON u.id = gu.user_id WHERE gu.game_id = ? AND gu.user_id = ? LIMIT 1 "); $stmt->execute([ $gameId, $userId ]); $gameUser = $stmt->fetch(); if (!$gameUser) { die('Game user not found.'); } /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * Author permission belongs to the user/game relationship, not to the user * globally. wf_game_users.role is therefore the canonical source. */ $canUseAuthorMode = strtolower( trim( (string)( $gameUser['game_user_role'] ?? '' ) ) ) === 'author'; // ================================================================ // RUNTIME STATE // ================================================================ $state = null; if (!empty($gameUser['state'])) { $decodedState = json_decode( $gameUser['state'], true ); if (is_array($decodedState)) { $state = $decodedState; } } /* * WAYFARER_ENTITY_RUNTIME_STATE_PAGE_RECOVERY_V49 * * Existing development playthroughs should gain Entity state immediately on * page load. This is additive and does not overwrite current runtime values. */ if ($state !== null) { $runtimeRecoveryChanged = wayfarerRuntimeEnsureEntityState( $pdo, (int)$gameId, $state ); /* WAYFARER_PLAYER_TRAITS_PAGE_RECOVERY_V52 */ if ( wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$gameId, $state ) ) { $runtimeRecoveryChanged = true; } if ($runtimeRecoveryChanged) { $encodedRecoveredState = wayfarerRuntimeEncodeState( $state ); $entityRecoveryStmt = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $entityRecoveryStmt->execute([ $encodedRecoveredState, (int)$gameUser['game_user_id'], ]); /* * Keep Author Debug and any later page component on the same snapshot. */ $gameUser['state'] = $encodedRecoveredState; } } // ================================================================ // INITIALIZE GAME // ================================================================ $initializationError = ''; if ( $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'initialize' && $state === null ) { try { wayfarerRuntimeInitializeGame( $pdo, (int)$gameUser['game_user_id'], [ 'player_names' => $_POST['player_names'] ?? [], 'choices' => $_POST['setup_choices'] ?? [], ] ); /* * Reload from persisted state so every play-page component sees the * exact same initialized snapshot. */ header('Location: index.php'); exit; } catch (Throwable $exception) { $initializationError = $exception->getMessage(); } } // ================================================================ // AUTHOR MODE: GAME TRAIT DEFINITIONS // ================================================================ $gameTraitDefinitions = []; $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } foreach ($gameConfig['traits'] ?? [] as $trait) { if (empty($trait['id'])) { continue; } $gameTraitDefinitions[$trait['id']] = $trait; } ?> <?= htmlspecialchars($gameUser['game_name']) ?>

Player:



================================================================================ FILE: update-backups/v62-20260925-224818/play-assets-play.js ================================================================================ /* |-------------------------------------------------------------------------- | PLAYER RUNTIME CONTROLLER | WAYFARER_ACTION_SCREEN_V39 | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | Runtime modes currently exposed to the player: | | presentation -> Action -> running navigation -> presentation/Action | | Presentation is persisted by PHP. Automatic tick advancement cannot continue | while a presentation is active. | */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; if (!config) { return; } const body = document.body; const tickValue = document.getElementById( 'wayfarer-tick-value' ); const statusValue = document.getElementById( 'wayfarer-tick-status' ); const message = document.getElementById( 'wayfarer-tick-message' ); const onceButton = document.getElementById( 'wayfarer-tick-once' ); const autoButton = document.getElementById( 'wayfarer-tick-auto' ); const authorPauseButton = document.getElementById( 'wayfarer-tick-pause' ); const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); /* WAYFARER_TRAVEL_TIME_HUD_V61 */ const travelTime = document.querySelector( '[data-wayfarer-travel-time]' ); const travelTimeSeparator = document.querySelector( '[data-wayfarer-travel-time-separator]' ); const travelNext = document.querySelector( '[data-wayfarer-travel-next]' ); const continueTravelButton = document.querySelector( '[data-wayfarer-continue-travel]' ); const playerPauseButton = document.querySelector( '[data-wayfarer-pause-travel]' ); /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Action Mode owns one physical card. Inventory, Status, and future * utility/context views temporarily replace the normal action heading/list * inside that card without becoming persisted runtime modes. */ const actionHeading = actionScreen ? actionScreen.querySelector( '.wayfarer-action-heading' ) : null; const actionList = actionScreen ? actionScreen.querySelector( '[data-wayfarer-action-list]' ) : null; const actionSubviewPanels = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ) : []; const actionViewOpeners = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-open-action-view]' ) ) : []; const actionViewClosers = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-close-action-view]' ) ) : []; /* * Persisted narrative presentation. */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); const presentationContinueButton = presentationScreen ? presentationScreen.querySelector( '[data-wayfarer-presentation-continue]' ) : null; let running = false; let requestInFlight = false; let presentationRequestInFlight = false; let storyletRequestInFlight = false; let timeoutId = null; let reloadWhenIdle = false; let presentationActive = Boolean( presentationScreen && !presentationScreen.hidden ); function setMessage( text, isError = false ) { if (!message) { return; } message.textContent = text; message.style.color = isError ? 'red' : ''; } function emitModeEvent() { let mode = 'paused'; if (presentationActive) { mode = 'presentation'; } else if (running) { mode = 'running'; } window.dispatchEvent( new CustomEvent( 'wayfarer:runtime-mode', { detail: { mode: mode } } ) ); /* * Keep the v39 event names available for existing development hooks. */ window.dispatchEvent( new CustomEvent( running ? 'wayfarer:runtime-running' : 'wayfarer:runtime-paused', { detail: { mode: mode } } ) ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Generic in-card view switcher. Future Map / Shop / other views can use * the same data-wayfarer-open-action-view + data-wayfarer-action-view * contract without adding another page-level layout system. */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Resolve available views at the moment navigation occurs instead of * relying on the V48 page-load snapshot. This supports nested Entity * detail screens and future context views added to the same Action card. */ function setActionView(viewName = 'actions') { if (!actionScreen) { return; } const requested = String( viewName || 'actions' ); const showActions = requested === 'actions'; const panels = Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ); let matched = showActions; panels.forEach( function (panel) { const active = panel.dataset .wayfarerActionView === requested; panel.hidden = !active; if (active) { matched = true; } } ); /* * Fail safely if a malformed/removed view name is requested. */ if (!matched) { panels.forEach( function (panel) { panel.hidden = true; } ); if (actionHeading) { actionHeading.hidden = false; } if (actionList) { actionList.hidden = false; } return; } if (actionHeading) { actionHeading.hidden = !showActions; } if (actionList) { actionList.hidden = !showActions; } } function renderControls() { if (statusValue) { statusValue.textContent = presentationActive ? 'Presentation' : ( running ? 'Running' : 'Paused' ); } if (onceButton) { onceButton.disabled = running || requestInFlight || presentationActive; } if (autoButton) { autoButton.disabled = running || requestInFlight || presentationActive; } if (authorPauseButton) { authorPauseButton.disabled = !running; } if (continueTravelButton) { continueTravelButton.disabled = running || requestInFlight || presentationActive; } document .querySelectorAll( '[data-wayfarer-storylet-action-id], ' + '[data-wayfarer-storylet-branch-id]' ) .forEach( function (button) { button.disabled = requestInFlight || presentationRequestInFlight || storyletRequestInFlight || ( button.hasAttribute( 'data-wayfarer-storylet-action-id' ) && (running || presentationActive) ); } ); if (playerPauseButton) { playerPauseButton.disabled = !running; } if (actionScreen) { actionScreen.hidden = running || presentationActive; } if (travelControls) { travelControls.hidden = !running || presentationActive; } if (presentationScreen) { presentationScreen.hidden = !presentationActive; presentationScreen.setAttribute( 'aria-hidden', presentationActive ? 'false' : 'true' ); } body.classList.toggle( 'wayfarer-runtime-running', running ); body.classList.toggle( 'wayfarer-runtime-paused', !running && !presentationActive ); body.classList.toggle( 'wayfarer-runtime-presentation', presentationActive ); } /* * WAYFARER_LIVE_ACTION_CONTEXT_V46_1 * * Action Mode must reflect the current runtime position, not the page's * original server-rendered context. */ function renderPlayerContext(context) { if ( !context || typeof context !== 'object' ) { return; } const heading = document.querySelector( '[data-wayfarer-action-context-name]' ); const parent = document.querySelector( '[data-wayfarer-action-parent-context]' ); if (heading) { const name = String( context.context_name || 'Actions' ).trim(); heading.textContent = name || 'Actions'; } if (parent) { const parentText = String( context.parent_context || '' ).trim(); parent.textContent = parentText; parent.hidden = parentText === ''; } } /* WAYFARER_TRAVEL_TIME_HUD_RENDER_V61 */ function renderTravelStatus(travel) { if (!travel || typeof travel !== 'object') { return; } const timeLabel = String(travel.time_label || '').trim(); if (travelTime) { travelTime.textContent = timeLabel; travelTime.hidden = timeLabel === ''; } if (travelTimeSeparator) { travelTimeSeparator.hidden = timeLabel === ''; } const nextLabel = String(travel.next_label || '').trim(); if (travelNext) { travelNext.textContent = nextLabel === '' ? '' : 'Next: ' + nextLabel; travelNext.hidden = nextLabel === ''; } } function displayRuntimeValue(value) { if (typeof value === 'boolean') { return value ? 'True' : 'False'; } if ( value === null || value === undefined ) { return 'null'; } if (typeof value === 'object') { return JSON.stringify( value ); } return String( value ); } /* * WAYFARER_STORYLET_ACTIONS_LIVE_V59 * * Tick/presentation APIs return the currently eligible root Manual * Storylets so Action Mode does not depend on the page-load snapshot. */ function renderStoryletActions(actions) { if (!actionScreen) { return; } const host = actionScreen.querySelector( '[data-wayfarer-storylet-actions]' ); if (!host) { return; } const normalized = Array.isArray(actions) ? actions : []; host.innerHTML = ''; normalized.forEach( function (action) { const id = Number( action && action.id ? action.id : 0 ); if (id <= 0) { return; } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-action-button'; button.dataset.wayfarerStoryletActionId = String(id); button.textContent = String( action.label || action.name || 'Storylet' ); host.appendChild( button ); } ); const empty = actionScreen.querySelector( '[data-wayfarer-action-empty]' ); if (empty) { const canTravel = actionScreen.dataset.wayfarerCanTravel === '1'; empty.hidden = canTravel || host.children.length > 0; } renderControls(); } function renderRuntimeState(state) { if ( !state || typeof state !== 'object' ) { return; } if ( state.navigation && state.navigation.position !== undefined ) { const position = String( state.navigation.position ); const authorPosition = document.getElementById( 'wayfarer-author-position' ); if (authorPosition) { authorPosition.textContent = position; } } const gameTraits = state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : {}; document .querySelectorAll( '[data-wayfarer-game-trait-value]' ) .forEach( function (cell) { const traitId = cell.dataset .wayfarerGameTraitValue; if ( Object.prototype .hasOwnProperty .call( gameTraits, traitId ) ) { cell.textContent = displayRuntimeValue( gameTraits[traitId] ); } } ); document .querySelectorAll( '[data-wayfarer-region-trait-value]' ) .forEach( function (cell) { const regionId = cell.dataset .wayfarerRegionId; const traitId = cell.dataset .wayfarerRegionTraitValue; const traits = state.regions && state.regions[regionId] && state.regions[regionId].traits && typeof state.regions[regionId].traits === 'object' ? state.regions[regionId].traits : null; if ( traits && Object.prototype .hasOwnProperty .call( traits, traitId ) ) { cell.textContent = displayRuntimeValue( traits[traitId] ); } } ); const debug = document.getElementById( 'wayfarer-runtime-debug' ); if (debug) { debug.textContent = JSON.stringify( state, null, 2 ); } } async function requestTick() { if ( requestInFlight || presentationActive ) { return null; } requestInFlight = true; renderControls(); try { const response = await fetch( config.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Tick endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Tick request failed.' ); } if (tickValue) { tickValue.textContent = String( data.tick ); } renderRuntimeState( data.state ); renderPlayerContext( data.context ); renderTravelStatus( data.travel ); renderStoryletActions( data.actions ); setMessage( 'Tick ' + data.tick + ' saved.' ); if (data.pause === true) { /* * Suppress Action Mode during the brief interval before reload. * The server-rendered presentation will exist after reload. */ presentationActive = true; pauseRuntime( data.pause_reason || 'Travel interrupted.', true ); } return data; } catch (error) { pauseRuntime( error instanceof Error ? error.message : 'Tick request failed.' ); setMessage( error instanceof Error ? error.message : 'Tick request failed.', true ); return null; } finally { requestInFlight = false; renderControls(); if (reloadWhenIdle) { reloadWhenIdle = false; window.location.reload(); } } } async function runAutomaticTick() { if ( !running || presentationActive ) { return; } const result = await requestTick(); if ( !running || presentationActive || result === null ) { return; } timeoutId = window.setTimeout( runAutomaticTick, Number( config.intervalMs ) || 1500 ); } function startRuntime() { if ( running || requestInFlight || presentationActive ) { return; } /* * Travel always leaves inspection/context views and returns the card to * its normal Action view for the next pause. */ setActionView( 'actions' ); running = true; setMessage( 'Automatic advancement started.' ); renderControls(); emitModeEvent(); runAutomaticTick(); } function pauseRuntime( reason = 'Automatic advancement paused.', reloadForPresentation = false ) { const wasRunning = running; running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } setMessage( reason ); if (wasRunning) { setActionView( 'actions' ); } renderControls(); if (wasRunning) { emitModeEvent(); } if (reloadForPresentation) { if (requestInFlight) { reloadWhenIdle = true; } else { window.location.reload(); } } } /* * WAYFARER_STORYLET_ACTION_RUNTIME_V59 */ async function requestStorylet( storyletId, entryMode = 'action', parentStoryletId = 0 ) { if ( storyletRequestInFlight || requestInFlight || presentationRequestInFlight ) { return; } storyletId = Number(storyletId || 0); parentStoryletId = Number(parentStoryletId || 0); if (storyletId <= 0) { return; } storyletRequestInFlight = true; renderControls(); try { const response = await fetch( config.storyletEndpoint || 'api/storylet.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, storylet_id: storyletId, entry_mode: entryMode, parent_storylet_id: parentStoryletId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Storylet endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not enter Storylet.' ); } /* * Storylet presentation/Branch markup is server-rendered. Reload * only after the runtime transaction succeeds, so Effects cannot be * repeated by a failed or duplicate browser transition. */ window.location.reload(); } catch (error) { window.alert( error instanceof Error ? error.message : 'Could not enter Storylet.' ); } finally { storyletRequestInFlight = false; renderControls(); } } async function dismissPresentation() { if ( !presentationActive || !presentationScreen || presentationRequestInFlight ) { return; } presentationRequestInFlight = true; if (presentationContinueButton) { presentationContinueButton.disabled = true; } const presentationType = presentationScreen.dataset .wayfarerPresentationType || ''; const presentationId = Number( presentationScreen.dataset .wayfarerPresentationId || 0 ); try { const response = await fetch( config.presentationEndpoint || 'api/presentation.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, type: presentationType, id: presentationId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Presentation endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not continue.' ); } presentationActive = false; setActionView( 'actions' ); renderRuntimeState( data.state ); renderPlayerContext( data.context ); renderStoryletActions( data.actions ); renderControls(); emitModeEvent(); /* * Place keyboard focus on the first available Action so the * transition works cleanly for keyboard players too. */ const firstAction = actionScreen ? actionScreen.querySelector( 'button:not([disabled]), a[href]' ) : null; if ( firstAction && typeof firstAction.focus === 'function' ) { firstAction.focus(); } } catch (error) { if (presentationContinueButton) { presentationContinueButton.disabled = false; } window.alert( error instanceof Error ? error.message : 'Could not continue.' ); } finally { presentationRequestInFlight = false; if ( presentationContinueButton && presentationActive ) { presentationContinueButton.disabled = false; } } } if (onceButton) { onceButton.addEventListener( 'click', async function () { if ( running || presentationActive ) { return; } setMessage(''); await requestTick(); } ); } if (autoButton) { autoButton.addEventListener( 'click', startRuntime ); } if (authorPauseButton) { authorPauseButton.addEventListener( 'click', function () { pauseRuntime( 'Automatic advancement paused.' ); } ); } if (continueTravelButton) { continueTravelButton.addEventListener( 'click', startRuntime ); } if (playerPauseButton) { playerPauseButton.addEventListener( 'click', function () { /* * WAYFARER_SMOOTH_MANUAL_PAUSE_V46_2 * * v46.1 keeps Action context current in-place, so manual Pause * no longer needs a full page reload. */ pauseRuntime( 'Travel paused.' ); } ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Ensure V50 detail panels physically belong to the Action card. This is * harmless when the PHP view already placed them correctly, and repairs * any earlier V50 markup that landed just outside the host. */ if (actionScreen) { document .querySelectorAll( '.wayfarer-entity-detail-view' ) .forEach( function (panel) { if ( !actionScreen.contains( panel ) ) { actionScreen.appendChild( panel ); } } ); /* * Delegate navigation from the Action card itself. Any current or * future control using the generic data attributes automatically * participates in the same router. */ actionScreen.addEventListener( 'click', function (event) { const storyletAction = event.target.closest( '[data-wayfarer-storylet-action-id]' ); if ( storyletAction && actionScreen.contains( storyletAction ) ) { if ( running || presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( storyletAction.dataset .wayfarerStoryletActionId, 'action', 0 ); return; } const opener = event.target.closest( '[data-wayfarer-open-action-view]' ); if ( opener && actionScreen.contains( opener ) ) { if ( running || presentationActive ) { return; } event.preventDefault(); setActionView( opener.dataset .wayfarerOpenActionView || 'actions' ); return; } const closer = event.target.closest( '[data-wayfarer-close-action-view]' ); if ( !closer || !actionScreen.contains( closer ) ) { return; } event.preventDefault(); const returnView = closer.dataset .wayfarerReturnActionView || 'actions'; setActionView( returnView ); let focusTarget = null; if (returnView === 'actions') { focusTarget = actionList ? actionList.querySelector( 'button:not([disabled]), a[href]' ) : null; } else { const parentPanel = actionScreen.querySelector( '[data-wayfarer-action-view="' + returnView + '"]' ); focusTarget = parentPanel ? parentPanel.querySelector( '[data-wayfarer-open-action-view], button:not([disabled]), a[href]' ) : null; } if ( focusTarget && typeof focusTarget.focus === 'function' ) { focusTarget.focus(); } } ); } /* WAYFARER_STORYLET_BRANCH_SELECTION_V59 */ document.addEventListener( 'click', function (event) { const branch = event.target.closest( '[data-wayfarer-storylet-branch-id]' ); if (!branch) { return; } if ( !presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( branch.dataset .wayfarerStoryletBranchId, 'branch', branch.dataset .wayfarerStoryletParentId ); } ); if (presentationContinueButton) { presentationContinueButton.addEventListener( 'click', dismissPresentation ); } document.addEventListener( 'keydown', function (event) { if ( !presentationActive || presentationRequestInFlight || storyletRequestInFlight || !presentationContinueButton ) { return; } const target = event.target; if ( target && target.matches && target.matches( 'input, textarea, select, button, a[href], [contenteditable="true"]' ) ) { return; } if ( event.key !== 'Enter' && event.key !== ' ' ) { return; } event.preventDefault(); dismissPresentation(); } ); window.addEventListener( 'pagehide', function () { running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } } ); window.wayfarerRuntimeController = { start: startRuntime, pause: pauseRuntime, dismissPresentation: dismissPresentation, enterStorylet: requestStorylet, isRunning: function () { return running; }, mode: function () { if (presentationActive) { return 'presentation'; } return running ? 'running' : 'paused'; } }; setActionView( 'actions' ); renderControls(); emitModeEvent(); })(); /* |-------------------------------------------------------------------------- | WAYFARER PLAY SHELL v20 |-------------------------------------------------------------------------- | | Reorganizes existing server-rendered nodes into the viewport shell. | Existing nodes are MOVED, not cloned/replaced, so the tick-control | event listeners installed above remain intact. | */ (() => { 'use strict'; if ( document.getElementById( 'wayfarer-play-shell' ) ) { return; } function exactHeading( level, text ) { const headings = document.querySelectorAll( level ); for (const heading of headings) { if ( heading.textContent .replace(/\s+/g, ' ') .trim() === text ) { return heading; } } return null; } function moveRange( start, stopExclusive, destination ) { if ( !start || !destination ) { return; } let node = start; while ( node && node !== stopExclusive ) { const next = node.nextSibling; destination.appendChild( node ); node = next; } } const body = document.body; /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * PHP decides whether Author Mode is permitted. JavaScript only shapes the * interface around that server-side decision. */ const canUseAuthorMode = body.dataset.wayfarerAuthorMode === '1'; body.classList.add( 'wayfarer-play-page' ); const shell = document.createElement( 'div' ); shell.id = 'wayfarer-play-shell'; shell.className = 'wayfarer-play-shell'; const header = document.createElement( 'header' ); header.className = 'wayfarer-play-shell-header'; const title = document.createElement( 'div' ); title.className = 'wayfarer-play-title'; const actions = document.createElement( 'div' ); actions.className = 'wayfarer-shell-actions'; const toggleAuthor = document.createElement( 'button' ); toggleAuthor.type = 'button'; toggleAuthor.className = 'wayfarer-shell-button'; if (canUseAuthorMode) { actions.appendChild( toggleAuthor ); } header.appendChild( title ); if (canUseAuthorMode) { header.appendChild( actions ); } const main = document.createElement( 'main' ); main.className = 'wayfarer-play-main'; const playerPane = document.createElement( 'section' ); playerPane.className = 'wayfarer-player-pane'; const authorPane = document.createElement( 'aside' ); authorPane.className = 'wayfarer-author-pane'; const authorHeader = document.createElement( 'div' ); authorHeader.className = 'wayfarer-author-header'; const authorToolbar = document.createElement( 'div' ); authorToolbar.className = 'wayfarer-author-toolbar'; const tabBar = document.createElement( 'div' ); tabBar.className = 'wayfarer-author-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const authorContent = document.createElement( 'div' ); authorContent.className = 'wayfarer-author-content'; authorPane.appendChild( authorHeader ); authorPane.appendChild( authorToolbar ); authorPane.appendChild( tabBar ); authorPane.appendChild( authorContent ); main.appendChild( playerPane ); if (canUseAuthorMode) { main.appendChild( authorPane ); } else { /* * Keep the player workspace full-width when Author Mode is unavailable. * No Author pane was rendered by PHP and none is attached here. */ shell.classList.add( 'author-collapsed' ); } shell.appendChild( header ); shell.appendChild( main ); body.insertBefore( shell, body.firstChild ); // ------------------------------------------------------------ // Top title/player identity // ------------------------------------------------------------ const pageTitle = Array.from( body.querySelectorAll( ':scope > h1' ) )[0]; if (pageTitle) { const playerIdentity = pageTitle.nextElementSibling; title.appendChild( pageTitle ); if ( playerIdentity && playerIdentity.tagName === 'P' ) { title.appendChild( playerIdentity ); } } // ------------------------------------------------------------ // Player content // ------------------------------------------------------------ /* * WAYFARER_PLAYER_START_SCREEN_V36 * * Before runtime state exists, the start screen owns the full player * workspace and Author Mode is intentionally hidden. */ const startScreen = document.querySelector( '[data-wayfarer-start-screen]' ); if (startScreen) { shell.classList.add( 'is-starting' ); playerPane.appendChild( startScreen ); } const scene = document.getElementById( 'wayfarer-player-scene' ); const gameStateHeading = exactHeading( 'h2', 'Game State' ) || exactHeading( 'h2', 'Game Not Initialized' ); if (gameStateHeading) { const meta = document.createElement( 'div' ); meta.className = 'wayfarer-player-meta'; /* * Move from Game State through the node immediately before the * player scene. This includes initialization messaging/forms but * stops before the actual scene. */ moveRange( gameStateHeading, scene, meta ); playerPane.appendChild( meta ); } if (scene) { playerPane.appendChild( scene ); } /* * WAYFARER_ACTION_SCREEN_V39 * * Action Mode is player-facing and exists independently of Author Mode. */ const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); if (actionScreen) { playerPane.appendChild( actionScreen ); } /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); if (presentationScreen) { playerPane.appendChild( presentationScreen ); } const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); if (travelControls) { playerPane.appendChild( travelControls ); } // ------------------------------------------------------------ // Author header // ------------------------------------------------------------ const authorHeading = exactHeading( 'h2', 'Author Mode' ); if (authorHeading) { const builderLinkBlock = authorHeading.nextElementSibling; authorHeader.appendChild( authorHeading ); if ( builderLinkBlock && builderLinkBlock.tagName === 'P' ) { authorHeader.appendChild( builderLinkBlock ); } } // ------------------------------------------------------------ // Runtime controls // ------------------------------------------------------------ const tickPanel = document.getElementById( 'wayfarer-tick-test' ); if (tickPanel) { authorToolbar.appendChild( tickPanel ); } // ------------------------------------------------------------ // Author tabs // ------------------------------------------------------------ /* WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 */ const panelDefinitions = [ { key: 'state', label: 'State' }, { key: 'debug', label: 'Debug' } ]; function activateTab(key) { document .querySelectorAll( '.wayfarer-author-tab' ) .forEach( function (button) { const active = button.dataset .wayfarerAuthorTab === key; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); authorContent .querySelectorAll( '.wayfarer-author-tab-panel' ) .forEach( function (panel) { panel.classList.toggle( 'is-active', panel.dataset .wayfarerAuthorPanel === key ); } ); try { window.localStorage.setItem( 'wayfarerAuthorTab', key ); } catch (error) { // Storage is optional. } } for (const definition of panelDefinitions) { const panel = document.querySelector( '[data-wayfarer-author-panel="' + definition.key + '"]' ); if (!panel) { continue; } authorContent.appendChild( panel ); const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-author-tab'; button.dataset.wayfarerAuthorTab = definition.key; button.setAttribute( 'role', 'tab' ); button.textContent = definition.label; button.addEventListener( 'click', function () { activateTab( definition.key ); } ); tabBar.appendChild( button ); } let initialTab = 'state'; try { let savedTab = window.localStorage.getItem( 'wayfarerAuthorTab' ); if ( savedTab === 'region' ) { savedTab = 'state'; window.localStorage.setItem( 'wayfarerAuthorTab', 'state' ); } if ( savedTab && authorContent.querySelector( '[data-wayfarer-author-panel="' + savedTab + '"]' ) ) { initialTab = savedTab; } } catch (error) { // Storage is optional. } activateTab( initialTab ); // ------------------------------------------------------------ // Hide / Show Author Mode // WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 // ------------------------------------------------------------ if (canUseAuthorMode) { function renderAuthorToggle() { const collapsed = shell.classList.contains( 'author-collapsed' ); toggleAuthor.textContent = collapsed ? 'Show Author' : 'Hide Author'; toggleAuthor.setAttribute( 'aria-expanded', collapsed ? 'false' : 'true' ); } toggleAuthor.addEventListener( 'click', function () { shell.classList.toggle( 'author-collapsed' ); try { window.localStorage.setItem( 'wayfarerAuthorCollapsed', shell.classList.contains( 'author-collapsed' ) ? '1' : '0' ); } catch (error) { // Storage is optional. } renderAuthorToggle(); } ); try { if ( window.localStorage.getItem( 'wayfarerAuthorCollapsed' ) === '1' ) { shell.classList.add( 'author-collapsed' ); } } catch (error) { // Storage is optional. } renderAuthorToggle(); } else { /* * Player role is not merely a hidden Author interface. Keep the shell * permanently in player-only layout. */ shell.classList.add( 'author-collapsed' ); } // ------------------------------------------------------------ // Remove otherwise-empty top-level HRs left by the old document flow. // ------------------------------------------------------------ body .querySelectorAll( ':scope > hr' ) .forEach( function (rule) { rule.remove(); } ); })(); ================================================================================ FILE: update-backups/v63-20260925-234422/play/assets/play.css ================================================================================ /* |-------------------------------------------------------------------------- | Wayfarer Play Shell v20 |-------------------------------------------------------------------------- */ html, body { height: 100%; } body.wayfarer-play-page { margin: 0; overflow: hidden; background: #eef1f5; color: #20242a; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.45; } .wayfarer-play-page * { box-sizing: border-box; } .wayfarer-play-page hr { display: none; } .wayfarer-play-shell { height: 100vh; display: flex; flex-direction: column; overflow: hidden; } .wayfarer-play-shell-header { flex: 0 0 auto; min-height: 68px; padding: 12px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; background: #ffffff; border-bottom: 1px solid #d8dbe2; } .wayfarer-play-title { min-width: 0; } .wayfarer-play-title h1 { margin: 0; font-size: 1.35rem; line-height: 1.2; } .wayfarer-play-title p { margin: 4px 0 0; color: #667085; font-size: 0.9rem; } .wayfarer-shell-actions { display: flex; align-items: center; gap: 8px; } .wayfarer-shell-button { border: 1px solid #b8bdc7; border-radius: 6px; padding: 7px 10px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 600; } .wayfarer-shell-button:hover { background: #f6f7f9; } .wayfarer-play-main { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 7fr) minmax(330px, 3fr); gap: 12px; padding: 12px; overflow: hidden; } .wayfarer-player-pane, .wayfarer-author-pane { min-width: 0; min-height: 0; background: #ffffff; border: 1px solid #d8dbe2; border-radius: 10px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); } .wayfarer-player-pane { overflow: auto; padding: 22px; } .wayfarer-player-pane > h2:first-child { margin-top: 0; } #wayfarer-player-scene { margin-top: 10px; } #wayfarer-player-scene h2 { margin-top: 0; } .wayfarer-author-pane { display: flex; flex-direction: column; overflow: hidden; } .wayfarer-author-header { flex: 0 0 auto; padding: 14px 16px 10px; border-bottom: 1px solid #e3e6eb; } .wayfarer-author-header h2 { margin: 0; font-size: 1.05rem; } .wayfarer-author-header p { margin: 6px 0 0; font-size: 0.85rem; } .wayfarer-author-header a { color: #2f5ea8; } .wayfarer-author-toolbar { flex: 0 0 auto; padding: 10px 14px; background: #f6f7f9; border-bottom: 1px solid #e3e6eb; } .wayfarer-author-toolbar #wayfarer-tick-test { margin: 0; } .wayfarer-author-toolbar #wayfarer-tick-test > h2 { margin: 0 0 6px; font-size: 0.9rem; } .wayfarer-author-toolbar #wayfarer-tick-test > p { margin: 5px 0; font-size: 0.82rem; } .wayfarer-author-toolbar button { margin-right: 4px; margin-bottom: 2px; padding: 5px 8px; } #wayfarer-tick-message { min-height: 1.2em; } .wayfarer-author-tabs { flex: 0 0 auto; display: flex; gap: 2px; padding: 8px 10px 0; background: #ffffff; border-bottom: 1px solid #d8dbe2; } .wayfarer-author-tab { appearance: none; border: 0; border-bottom: 2px solid transparent; padding: 8px 10px 7px; background: transparent; color: #667085; cursor: pointer; font: inherit; font-size: 0.86rem; font-weight: 600; } .wayfarer-author-tab:hover { color: #20242a; } .wayfarer-author-tab.is-active { color: #2f5ea8; border-bottom-color: #2f5ea8; } .wayfarer-author-content { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 14px 16px 18px; } .wayfarer-author-tab-panel { display: none; } .wayfarer-author-tab-panel.is-active { display: block; } .wayfarer-author-tab-panel > :first-child { margin-top: 0; } .wayfarer-author-tab-panel h3 { margin-top: 0; font-size: 1rem; } .wayfarer-author-tab-panel h4 { margin-bottom: 8px; } .wayfarer-author-tab-panel table { width: 100%; border-collapse: collapse; font-size: 0.84rem; } .wayfarer-author-tab-panel th, .wayfarer-author-tab-panel td { padding: 6px 7px; text-align: left; vertical-align: top; } .wayfarer-author-tab-panel pre { max-width: 100%; overflow: auto; font-size: 0.78rem; } #wayfarer-author-debug-panel details { margin-top: 0 !important; } #wayfarer-author-debug-panel summary { display: none; } #wayfarer-author-debug-panel details:not([open]) > *:not(summary) { display: block; } #wayfarer-author-debug-panel details > pre { display: block; max-height: none !important; margin-top: 0 !important; } .wayfarer-player-meta { margin-bottom: 14px; padding: 10px 12px; border-radius: 7px; background: #f6f7f9; color: #667085; font-size: 0.86rem; } .wayfarer-player-meta h2 { margin: 0 0 4px; color: #20242a; font-size: 0.95rem; } .wayfarer-player-meta p { margin: 0; } .wayfarer-play-shell.author-collapsed .wayfarer-play-main { grid-template-columns: minmax(0, 1fr); } .wayfarer-play-shell.author-collapsed .wayfarer-author-pane { display: none; } @media (max-width: 900px) { body.wayfarer-play-page { overflow: auto; } .wayfarer-play-shell { height: auto; min-height: 100vh; overflow: visible; } .wayfarer-play-main { display: block; overflow: visible; padding: 8px; } .wayfarer-player-pane, .wayfarer-author-pane { overflow: visible; margin-bottom: 10px; } .wayfarer-author-pane { min-height: 480px; } .wayfarer-author-content { overflow: visible; } .wayfarer-play-shell.author-collapsed .wayfarer-author-pane { display: none; } } /* ------------------------------------------------------------------ GAME START SCREEN WAYFARER_PLAYER_START_SCREEN_V36 ------------------------------------------------------------------ */ /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * The start screen no longer implies "no Author Mode." Authors may inspect * the Author panel before initialization. The existing author-collapsed class * controls the one-column layout for normal players and collapsed authors. */ .wayfarer-start-screen { width: min(860px, 100%); margin: 0 auto; } .wayfarer-start-screen-heading { margin-bottom: 24px; } .wayfarer-start-screen-heading h2 { margin: 0 0 12px; font-size: 1.65rem; } .wayfarer-start-introduction { max-width: 72ch; color: #4b5563; line-height: 1.65; } .wayfarer-start-error { margin-bottom: 18px; padding: 10px 12px; border: 1px solid #e4a7ad; border-radius: 7px; background: #fff4f5; color: #8b1e2d; } .wayfarer-start-form { display: grid; gap: 22px; } .wayfarer-start-section, .wayfarer-start-choice { margin: 0; padding: 18px; border: 1px solid #d8dbe2; border-radius: 9px; background: #ffffff; } .wayfarer-start-section h3, .wayfarer-start-choice legend { margin: 0 0 14px; color: #20242a; font-size: 1.05rem; font-weight: 700; } .wayfarer-start-choice legend { padding: 0 6px; } .wayfarer-start-names { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } .wayfarer-start-names label { display: grid; gap: 5px; font-weight: 600; } .wayfarer-start-names input { width: 100%; padding: 9px 10px; border: 1px solid #b8bdc7; border-radius: 6px; font: inherit; } .wayfarer-start-choice { display: grid; gap: 10px; } .wayfarer-start-option { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: start; padding: 12px 13px; border: 1px solid #d8dbe2; border-radius: 7px; cursor: pointer; } .wayfarer-start-option:hover { background: #f6f7f9; } .wayfarer-start-option input { margin-top: 4px; } .wayfarer-start-option-copy { display: grid; gap: 4px; } .wayfarer-start-option-copy > span { color: #667085; font-size: 0.92rem; } .wayfarer-start-actions { display: flex; justify-content: flex-end; } .wayfarer-start-button { padding: 10px 18px; border: 1px solid #244b88; border-radius: 7px; background: #2f5ea8; color: #ffffff; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-start-button:hover { background: #244b88; } @media (max-width: 700px) { .wayfarer-start-screen { width: 100%; } .wayfarer-start-section, .wayfarer-start-choice { padding: 14px; } } /* ------------------------------------------------------------------ PLAYER ACTION SCREEN WAYFARER_ACTION_SCREEN_V39 ------------------------------------------------------------------ */ .wayfarer-action-screen { margin-top: 14px; padding: 18px; border: 1px solid #d8dbe2; border-radius: 10px; background: #ffffff; box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); } .wayfarer-action-screen[hidden], .wayfarer-travel-controls[hidden] { display: none !important; } .wayfarer-action-heading { margin-bottom: 14px; } .wayfarer-action-heading h2 { margin: 0 0 4px; font-size: 1.25rem; } .wayfarer-action-heading p { margin: 0; color: #667085; } .wayfarer-action-list { display: grid; gap: 10px; } .wayfarer-action-button { width: 100%; padding: 12px 14px; border: 1px solid #214b86; border-radius: 8px; background: #245a9b; color: #ffffff; cursor: pointer; font: inherit; font-weight: 700; text-align: left; } .wayfarer-action-button:hover { background: #1e4c84; } .wayfarer-action-button:disabled { cursor: default; opacity: 0.55; } /* ------------------------------------------------------------------ IN-CARD ACTION UTILITY VIEWS WAYFARER_ACTION_UTILITY_VIEWS_V48 ------------------------------------------------------------------ */ .wayfarer-action-utility-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 2px; } .wayfarer-action-utility-button, .wayfarer-action-back-button { padding: 10px 12px; border: 1px solid #b8bdc7; border-radius: 7px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 650; } .wayfarer-action-utility-button { width: 100%; text-align: center; } .wayfarer-action-utility-button:hover, .wayfarer-action-back-button:hover { border-color: #98a2b3; background: #f6f7f9; } .wayfarer-action-subview[hidden], .wayfarer-action-heading[hidden], .wayfarer-action-list[hidden] { display: none !important; } .wayfarer-action-subview-heading { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 18px; } .wayfarer-action-subview-heading h2 { margin: 0; font-size: 1.25rem; } .wayfarer-action-back-button { flex: 0 0 auto; } .wayfarer-action-subview-body { min-height: 90px; } /* ------------------------------------------------------------------ ENTITY INVENTORY / STATUS PRESENTATION WAYFARER_ENTITY_PRESENTATION_V49 ------------------------------------------------------------------ */ .wayfarer-inventory-list, .wayfarer-party-list { display: grid; gap: 10px; } .wayfarer-inventory-item, .wayfarer-party-member, .wayfarer-status-section { padding: 13px 14px; border: 1px solid #d8dbe2; border-radius: 8px; background: #f8fafc; } .wayfarer-inventory-item-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; } .wayfarer-inventory-quantity { flex: 0 0 auto; color: #667085; font-size: 0.9rem; font-weight: 650; } .wayfarer-status-section + .wayfarer-status-section { margin-top: 12px; } .wayfarer-status-section > h3 { margin: 0 0 10px; font-size: 1rem; } .wayfarer-status-player-name { margin: 0; font-weight: 700; } .wayfarer-party-member { background: #ffffff; } .wayfarer-party-member h4 { margin: 0; font-size: 0.98rem; } /* WAYFARER_PLAYER_TRAITS_STATUS_V52 */ .wayfarer-player-member h4::after { content: "Player"; margin-left: 8px; color: #667085; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; } .wayfarer-status-values { margin: 10px 0 0; } .wayfarer-status-values > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; padding: 6px 0; border-top: 1px solid #e3e6eb; } .wayfarer-status-values dt { color: #4b5563; } .wayfarer-status-values dd { margin: 0; font-weight: 700; text-align: right; } .wayfarer-status-muted { margin: 8px 0 0; color: #667085; font-size: 0.88rem; } /* ------------------------------------------------------------------ ENTITY DETAIL VIEW WAYFARER_ENTITY_DETAIL_VIEW_V50 ------------------------------------------------------------------ */ .wayfarer-entity-open-button { appearance: none; border: 0; padding: 0; background: transparent; color: #2f5ea8; cursor: pointer; font: inherit; font-weight: 750; text-align: left; } .wayfarer-entity-open-button:hover { text-decoration: underline; } .wayfarer-entity-open-button:focus-visible { outline: 2px solid #2f5ea8; outline-offset: 3px; border-radius: 2px; } .wayfarer-status-player-names { display: grid; gap: 4px; } .wayfarer-status-player-names .wayfarer-status-player-name { margin: 0; } .wayfarer-entity-detail-eyebrow { margin: 0 0 3px; color: #667085; font-size: 0.78rem; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; } .wayfarer-entity-detail-layout { display: grid; grid-template-columns: minmax(180px, 280px) minmax(0, 1fr); gap: 22px; align-items: start; } .wayfarer-entity-detail-layout:not(:has(.wayfarer-entity-detail-media)) { grid-template-columns: minmax(0, 1fr); } .wayfarer-entity-detail-media { margin: 0; } .wayfarer-entity-detail-media img { display: block; width: 100%; max-height: 340px; object-fit: contain; border-radius: 8px; background: #f6f7f9; } .wayfarer-entity-detail-description { margin: 0 0 16px; line-height: 1.6; } .wayfarer-entity-detail-copy > :first-child { margin-top: 0; } @media (max-width: 700px) { .wayfarer-entity-detail-layout { grid-template-columns: 1fr; } .wayfarer-entity-detail-media img { max-height: 280px; } } .wayfarer-action-empty { margin: 0; color: #667085; font-size: 0.92rem; } .wayfarer-travel-controls { margin-top: 14px; padding: 12px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid #d8dbe2; border-radius: 10px; background: #ffffff; } .wayfarer-travel-status { display: flex; align-items: center; gap: 9px; font-weight: 700; } /* WAYFARER_TRAVEL_TIME_HUD_V61 */ .wayfarer-travel-copy { min-width: 0; } .wayfarer-travel-primary { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px; } .wayfarer-travel-time { font-weight: 800; } .wayfarer-travel-time-separator { color: #98a2b3; } .wayfarer-travel-next { margin-top: 3px; color: #667085; font-size: 0.88rem; font-weight: 600; } .wayfarer-travel-indicator { width: 9px; height: 9px; border-radius: 999px; background: currentColor; } .wayfarer-travel-pause { padding: 8px 12px; border: 1px solid #aab4c3; border-radius: 7px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 600; } .wayfarer-travel-pause:hover { background: #f6f7f9; } @media (max-width: 620px) { .wayfarer-action-screen { padding: 14px; } .wayfarer-travel-controls { align-items: stretch; flex-direction: column; } .wayfarer-travel-pause { width: 100%; } .wayfarer-action-utility-row { grid-template-columns: 1fr; } } /* ------------------------------------------------------------------ LOCATION / STORY PRESENTATION WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 ------------------------------------------------------------------ */ /* * Scene prose now belongs to Presentation Mode rather than the normal Action * page. The server-rendered scene remains in the DOM temporarily for runtime * compatibility but is not player-facing. */ #wayfarer-player-scene { display: none; } .wayfarer-action-parent-context { margin-bottom: 4px !important; font-size: 0.85rem; font-weight: 600; } /* ------------------------------------------------------------------ STORYLET ACTIONS / BRANCHES WAYFARER_STORYLET_ACTION_RUNTIME_V59 ------------------------------------------------------------------ */ .wayfarer-storylet-action-list { display: grid; gap: 10px; } .wayfarer-storylet-action-list:empty { display: none; } .wayfarer-storylet-branch-list { width: 100%; display: grid; gap: 10px; } .wayfarer-presentation-actions.wayfarer-has-storylet-branches > div:not(.wayfarer-storylet-branch-list) { display: none; } .wayfarer-storylet-branch-prompt { margin: 0 0 4px; color: #b8c0cc; font-weight: 700; } .wayfarer-storylet-branch-button { width: 100%; padding: 12px 14px; border: 1px solid #f8fafc; border-radius: 8px; background: transparent; color: #f8fafc; cursor: pointer; font: inherit; font-weight: 700; text-align: left; } .wayfarer-storylet-branch-button:hover, .wayfarer-storylet-branch-button:focus-visible { background: #f8fafc; color: #15181d; } .wayfarer-storylet-branch-button:disabled { cursor: default; opacity: 0.55; } .wayfarer-presentation-screen { position: fixed; z-index: 10000; inset: 0; overflow-y: auto; padding: clamp(24px, 6vh, 72px) 24px; background: #15181d; color: #f8fafc; } .wayfarer-presentation-screen[hidden] { display: none !important; } .wayfarer-presentation-inner { width: min(1040px, 100%); min-height: calc(100vh - clamp(48px, 12vh, 144px)); margin: 0 auto; display: flex; flex-direction: column; justify-content: center; } .wayfarer-presentation-media { width: 100%; margin: 0 0 26px; overflow: hidden; border-radius: 12px; background: #0d0f12; } .wayfarer-presentation-media img { display: block; width: 100%; max-height: 52vh; object-fit: cover; } .wayfarer-presentation-copy { width: min(760px, 100%); margin: 0 auto; } .wayfarer-presentation-eyebrow { margin: 0 0 8px; color: #b8c0cc; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .wayfarer-presentation-copy h1 { margin: 0 0 18px; color: inherit; font-size: clamp(2rem, 5vw, 3.8rem); line-height: 1.05; } .wayfarer-presentation-description { font-size: clamp(1rem, 2vw, 1.22rem); line-height: 1.7; } .wayfarer-presentation-actions { width: min(760px, 100%); margin: 32px auto 0; display: flex; justify-content: flex-end; } .wayfarer-presentation-continue { min-width: 150px; padding: 11px 18px; border: 1px solid #f8fafc; border-radius: 8px; background: #f8fafc; color: #15181d; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-presentation-continue:hover { background: #e8edf3; } .wayfarer-presentation-continue:focus-visible { outline: 3px solid #ffffff; outline-offset: 4px; } .wayfarer-presentation-key-hint { margin: 10px 0 0; color: #9ea8b6; font-size: 0.78rem; text-align: right; } body.wayfarer-runtime-presentation { overflow: hidden; } @media (max-width: 620px) { .wayfarer-presentation-screen { padding: 20px 14px; } .wayfarer-presentation-inner { min-height: calc(100vh - 40px); } .wayfarer-presentation-actions { justify-content: stretch; } .wayfarer-presentation-continue { width: 100%; } } /* ------------------------------------------------------------------ COMBINED AUTHOR STATE + REGION WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 ------------------------------------------------------------------ */ .wayfarer-author-state-region { margin-top: 20px; padding-top: 14px; border-top: 1px solid #e4e7ec; } .wayfarer-author-state-region > :first-child { margin-top: 0; } /* ------------------------------------------------------------------ TERMINAL GAME ENDING WAYFARER_GAME_ENDING_SCREEN_V45 ------------------------------------------------------------------ */ .wayfarer-ending-screen { position: fixed; z-index: 11000; inset: 0; overflow-y: auto; padding: clamp(24px, 6vh, 72px) 24px; background: #11151a; color: #f8fafc; } .wayfarer-ending-inner { width: min(1040px, 100%); min-height: calc(100vh - clamp(48px, 12vh, 144px)); margin: 0 auto; display: flex; flex-direction: column; justify-content: center; } .wayfarer-ending-media { width: 100%; margin: 0 0 28px; overflow: hidden; border-radius: 12px; background: #080a0d; } .wayfarer-ending-media img { display: block; width: 100%; max-height: 52vh; object-fit: cover; } .wayfarer-ending-copy { width: min(760px, 100%); margin: 0 auto; } .wayfarer-ending-eyebrow { margin: 0 0 8px; color: #aeb8c5; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .wayfarer-ending-copy h1 { margin: 0 0 18px; color: inherit; font-size: clamp(2rem, 5vw, 3.8rem); line-height: 1.05; } .wayfarer-ending-text { font-size: clamp(1rem, 2vw, 1.22rem); line-height: 1.7; } body.wayfarer-game-ended { overflow: hidden; } ================================================================================ FILE: update-backups/v63-20260925-234422/play/assets/play.js ================================================================================ /* |-------------------------------------------------------------------------- | PLAYER RUNTIME CONTROLLER | WAYFARER_ACTION_SCREEN_V39 | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | Runtime modes currently exposed to the player: | | presentation -> Action -> running navigation -> presentation/Action | | Presentation is persisted by PHP. Automatic tick advancement cannot continue | while a presentation is active. | */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; if (!config) { return; } const body = document.body; const tickValue = document.getElementById( 'wayfarer-tick-value' ); const statusValue = document.getElementById( 'wayfarer-tick-status' ); const message = document.getElementById( 'wayfarer-tick-message' ); const onceButton = document.getElementById( 'wayfarer-tick-once' ); const autoButton = document.getElementById( 'wayfarer-tick-auto' ); const authorPauseButton = document.getElementById( 'wayfarer-tick-pause' ); const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); /* WAYFARER_TRAVEL_TIME_HUD_V61 */ const travelTime = document.querySelector( '[data-wayfarer-travel-time]' ); const travelTimeSeparator = document.querySelector( '[data-wayfarer-travel-time-separator]' ); const travelNext = document.querySelector( '[data-wayfarer-travel-next]' ); const continueTravelButton = document.querySelector( '[data-wayfarer-continue-travel]' ); const playerPauseButton = document.querySelector( '[data-wayfarer-pause-travel]' ); /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Action Mode owns one physical card. Inventory, Status, and future * utility/context views temporarily replace the normal action heading/list * inside that card without becoming persisted runtime modes. */ const actionHeading = actionScreen ? actionScreen.querySelector( '.wayfarer-action-heading' ) : null; const actionList = actionScreen ? actionScreen.querySelector( '[data-wayfarer-action-list]' ) : null; const actionSubviewPanels = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ) : []; const actionViewOpeners = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-open-action-view]' ) ) : []; const actionViewClosers = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-close-action-view]' ) ) : []; /* * Persisted narrative presentation. */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); const presentationContinueButton = presentationScreen ? presentationScreen.querySelector( '[data-wayfarer-presentation-continue]' ) : null; let running = false; let requestInFlight = false; let presentationRequestInFlight = false; let storyletRequestInFlight = false; let timeoutId = null; let reloadWhenIdle = false; let presentationActive = Boolean( presentationScreen && !presentationScreen.hidden ); function setMessage( text, isError = false ) { if (!message) { return; } message.textContent = text; message.style.color = isError ? 'red' : ''; } function emitModeEvent() { let mode = 'paused'; if (presentationActive) { mode = 'presentation'; } else if (running) { mode = 'running'; } window.dispatchEvent( new CustomEvent( 'wayfarer:runtime-mode', { detail: { mode: mode } } ) ); /* * Keep the v39 event names available for existing development hooks. */ window.dispatchEvent( new CustomEvent( running ? 'wayfarer:runtime-running' : 'wayfarer:runtime-paused', { detail: { mode: mode } } ) ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Generic in-card view switcher. Future Map / Shop / other views can use * the same data-wayfarer-open-action-view + data-wayfarer-action-view * contract without adding another page-level layout system. */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Resolve available views at the moment navigation occurs instead of * relying on the V48 page-load snapshot. This supports nested Entity * detail screens and future context views added to the same Action card. */ function setActionView(viewName = 'actions') { if (!actionScreen) { return; } const requested = String( viewName || 'actions' ); const showActions = requested === 'actions'; const panels = Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ); let matched = showActions; panels.forEach( function (panel) { const active = panel.dataset .wayfarerActionView === requested; panel.hidden = !active; if (active) { matched = true; } } ); /* * Fail safely if a malformed/removed view name is requested. */ if (!matched) { panels.forEach( function (panel) { panel.hidden = true; } ); if (actionHeading) { actionHeading.hidden = false; } if (actionList) { actionList.hidden = false; } return; } if (actionHeading) { actionHeading.hidden = !showActions; } if (actionList) { actionList.hidden = !showActions; } } function renderControls() { if (statusValue) { statusValue.textContent = presentationActive ? 'Presentation' : ( running ? 'Running' : 'Paused' ); } if (onceButton) { onceButton.disabled = running || requestInFlight || presentationActive; } if (autoButton) { autoButton.disabled = running || requestInFlight || presentationActive; } if (authorPauseButton) { authorPauseButton.disabled = !running; } if (continueTravelButton) { continueTravelButton.disabled = running || requestInFlight || presentationActive; } document .querySelectorAll( '[data-wayfarer-storylet-action-id], ' + '[data-wayfarer-storylet-branch-id]' ) .forEach( function (button) { button.disabled = requestInFlight || presentationRequestInFlight || storyletRequestInFlight || ( button.hasAttribute( 'data-wayfarer-storylet-action-id' ) && (running || presentationActive) ); } ); if (playerPauseButton) { playerPauseButton.disabled = !running; } if (actionScreen) { actionScreen.hidden = running || presentationActive; } if (travelControls) { travelControls.hidden = !running || presentationActive; } if (presentationScreen) { presentationScreen.hidden = !presentationActive; presentationScreen.setAttribute( 'aria-hidden', presentationActive ? 'false' : 'true' ); } body.classList.toggle( 'wayfarer-runtime-running', running ); body.classList.toggle( 'wayfarer-runtime-paused', !running && !presentationActive ); body.classList.toggle( 'wayfarer-runtime-presentation', presentationActive ); } /* * WAYFARER_LIVE_ACTION_CONTEXT_V46_1 * * Action Mode must reflect the current runtime position, not the page's * original server-rendered context. */ function renderPlayerContext(context) { if ( !context || typeof context !== 'object' ) { return; } const heading = document.querySelector( '[data-wayfarer-action-context-name]' ); const parent = document.querySelector( '[data-wayfarer-action-parent-context]' ); if (heading) { const name = String( context.context_name || 'Actions' ).trim(); heading.textContent = name || 'Actions'; } if (parent) { const parentText = String( context.parent_context || '' ).trim(); parent.textContent = parentText; parent.hidden = parentText === ''; } } /* WAYFARER_TRAVEL_TIME_HUD_RENDER_V61 */ function renderTravelStatus(travel) { if (!travel || typeof travel !== 'object') { return; } const timeLabel = String(travel.time_label || '').trim(); if (travelTime) { travelTime.textContent = timeLabel; travelTime.hidden = timeLabel === ''; } if (travelTimeSeparator) { travelTimeSeparator.hidden = timeLabel === ''; } const nextLabel = String(travel.next_label || '').trim(); if (travelNext) { travelNext.textContent = nextLabel === '' ? '' : 'Next: ' + nextLabel; travelNext.hidden = nextLabel === ''; } } function displayRuntimeValue(value) { if (typeof value === 'boolean') { return value ? 'True' : 'False'; } if ( value === null || value === undefined ) { return 'null'; } if (typeof value === 'object') { return JSON.stringify( value ); } return String( value ); } /* * WAYFARER_STORYLET_ACTIONS_LIVE_V59 * * Tick/presentation APIs return the currently eligible root Manual * Storylets so Action Mode does not depend on the page-load snapshot. */ function renderStoryletActions(actions) { if (!actionScreen) { return; } const host = actionScreen.querySelector( '[data-wayfarer-storylet-actions]' ); if (!host) { return; } const normalized = Array.isArray(actions) ? actions : []; host.innerHTML = ''; normalized.forEach( function (action) { const id = Number( action && action.id ? action.id : 0 ); if (id <= 0) { return; } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-action-button'; button.dataset.wayfarerStoryletActionId = String(id); button.textContent = String( action.label || action.name || 'Storylet' ); host.appendChild( button ); } ); const empty = actionScreen.querySelector( '[data-wayfarer-action-empty]' ); if (empty) { const canTravel = actionScreen.dataset.wayfarerCanTravel === '1'; empty.hidden = canTravel || host.children.length > 0; } renderControls(); } function renderRuntimeState(state) { if ( !state || typeof state !== 'object' ) { return; } if ( state.navigation && state.navigation.position !== undefined ) { const position = String( state.navigation.position ); const authorPosition = document.getElementById( 'wayfarer-author-position' ); if (authorPosition) { authorPosition.textContent = position; } } const gameTraits = state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : {}; document .querySelectorAll( '[data-wayfarer-game-trait-value]' ) .forEach( function (cell) { const traitId = cell.dataset .wayfarerGameTraitValue; if ( Object.prototype .hasOwnProperty .call( gameTraits, traitId ) ) { cell.textContent = displayRuntimeValue( gameTraits[traitId] ); } } ); document .querySelectorAll( '[data-wayfarer-region-trait-value]' ) .forEach( function (cell) { const regionId = cell.dataset .wayfarerRegionId; const traitId = cell.dataset .wayfarerRegionTraitValue; const traits = state.regions && state.regions[regionId] && state.regions[regionId].traits && typeof state.regions[regionId].traits === 'object' ? state.regions[regionId].traits : null; if ( traits && Object.prototype .hasOwnProperty .call( traits, traitId ) ) { cell.textContent = displayRuntimeValue( traits[traitId] ); } } ); const debug = document.getElementById( 'wayfarer-runtime-debug' ); if (debug) { debug.textContent = JSON.stringify( state, null, 2 ); } } async function requestTick() { if ( requestInFlight || presentationActive ) { return null; } requestInFlight = true; renderControls(); try { const response = await fetch( config.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Tick endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Tick request failed.' ); } if (tickValue) { tickValue.textContent = String( data.tick ); } renderRuntimeState( data.state ); renderPlayerContext( data.context ); renderTravelStatus( data.travel ); renderStoryletActions( data.actions ); setMessage( 'Tick ' + data.tick + ' saved.' ); if (data.pause === true) { /* * Suppress Action Mode during the brief interval before reload. * The server-rendered presentation will exist after reload. */ presentationActive = true; pauseRuntime( data.pause_reason || 'Travel interrupted.', true ); } return data; } catch (error) { pauseRuntime( error instanceof Error ? error.message : 'Tick request failed.' ); setMessage( error instanceof Error ? error.message : 'Tick request failed.', true ); return null; } finally { requestInFlight = false; renderControls(); if (reloadWhenIdle) { reloadWhenIdle = false; window.location.reload(); } } } async function runAutomaticTick() { if ( !running || presentationActive ) { return; } const result = await requestTick(); if ( !running || presentationActive || result === null ) { return; } timeoutId = window.setTimeout( runAutomaticTick, Number( config.intervalMs ) || 1500 ); } function startRuntime() { if ( running || requestInFlight || presentationActive ) { return; } /* * Travel always leaves inspection/context views and returns the card to * its normal Action view for the next pause. */ setActionView( 'actions' ); running = true; setMessage( 'Automatic advancement started.' ); renderControls(); emitModeEvent(); runAutomaticTick(); } function pauseRuntime( reason = 'Automatic advancement paused.', reloadForPresentation = false ) { const wasRunning = running; running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } setMessage( reason ); if (wasRunning) { setActionView( 'actions' ); } renderControls(); if (wasRunning) { emitModeEvent(); } if (reloadForPresentation) { if (requestInFlight) { reloadWhenIdle = true; } else { window.location.reload(); } } } /* * WAYFARER_STORYLET_ACTION_RUNTIME_V59 */ async function requestStorylet( storyletId, entryMode = 'action', parentStoryletId = 0 ) { if ( storyletRequestInFlight || requestInFlight || presentationRequestInFlight ) { return; } storyletId = Number(storyletId || 0); parentStoryletId = Number(parentStoryletId || 0); if (storyletId <= 0) { return; } storyletRequestInFlight = true; renderControls(); try { const response = await fetch( config.storyletEndpoint || 'api/storylet.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, storylet_id: storyletId, entry_mode: entryMode, parent_storylet_id: parentStoryletId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Storylet endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not enter Storylet.' ); } /* * Storylet presentation/Branch markup is server-rendered. Reload * only after the runtime transaction succeeds, so Effects cannot be * repeated by a failed or duplicate browser transition. */ window.location.reload(); } catch (error) { window.alert( error instanceof Error ? error.message : 'Could not enter Storylet.' ); } finally { storyletRequestInFlight = false; renderControls(); } } async function dismissPresentation() { if ( !presentationActive || !presentationScreen || presentationRequestInFlight ) { return; } presentationRequestInFlight = true; if (presentationContinueButton) { presentationContinueButton.disabled = true; } const presentationType = presentationScreen.dataset .wayfarerPresentationType || ''; const presentationId = Number( presentationScreen.dataset .wayfarerPresentationId || 0 ); try { const response = await fetch( config.presentationEndpoint || 'api/presentation.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, type: presentationType, id: presentationId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Presentation endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not continue.' ); } presentationActive = false; setActionView( 'actions' ); renderRuntimeState( data.state ); renderPlayerContext( data.context ); renderStoryletActions( data.actions ); renderControls(); emitModeEvent(); /* * Place keyboard focus on the first available Action so the * transition works cleanly for keyboard players too. */ const firstAction = actionScreen ? actionScreen.querySelector( 'button:not([disabled]), a[href]' ) : null; if ( firstAction && typeof firstAction.focus === 'function' ) { firstAction.focus(); } } catch (error) { if (presentationContinueButton) { presentationContinueButton.disabled = false; } window.alert( error instanceof Error ? error.message : 'Could not continue.' ); } finally { presentationRequestInFlight = false; /* WAYFARER_PRESENTATION_CONTROL_RESET_V62 * * dismissPresentation() previously refreshed Action controls while * presentationRequestInFlight was still true. Newly-rendered Manual * Storylet buttons were therefore left disabled after a Location or * Storylet presentation was dismissed. Re-render once the request * flag has actually been cleared. */ renderControls(); if ( presentationContinueButton && presentationActive ) { presentationContinueButton.disabled = false; } } } if (onceButton) { onceButton.addEventListener( 'click', async function () { if ( running || presentationActive ) { return; } setMessage(''); await requestTick(); } ); } if (autoButton) { autoButton.addEventListener( 'click', startRuntime ); } if (authorPauseButton) { authorPauseButton.addEventListener( 'click', function () { pauseRuntime( 'Automatic advancement paused.' ); } ); } if (continueTravelButton) { continueTravelButton.addEventListener( 'click', startRuntime ); } if (playerPauseButton) { playerPauseButton.addEventListener( 'click', function () { /* * WAYFARER_SMOOTH_MANUAL_PAUSE_V46_2 * * v46.1 keeps Action context current in-place, so manual Pause * no longer needs a full page reload. */ pauseRuntime( 'Travel paused.' ); } ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Ensure V50 detail panels physically belong to the Action card. This is * harmless when the PHP view already placed them correctly, and repairs * any earlier V50 markup that landed just outside the host. */ if (actionScreen) { document .querySelectorAll( '.wayfarer-entity-detail-view' ) .forEach( function (panel) { if ( !actionScreen.contains( panel ) ) { actionScreen.appendChild( panel ); } } ); /* * Delegate navigation from the Action card itself. Any current or * future control using the generic data attributes automatically * participates in the same router. */ actionScreen.addEventListener( 'click', function (event) { const storyletAction = event.target.closest( '[data-wayfarer-storylet-action-id]' ); if ( storyletAction && actionScreen.contains( storyletAction ) ) { if ( running || presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( storyletAction.dataset .wayfarerStoryletActionId, 'action', 0 ); return; } const opener = event.target.closest( '[data-wayfarer-open-action-view]' ); if ( opener && actionScreen.contains( opener ) ) { if ( running || presentationActive ) { return; } event.preventDefault(); setActionView( opener.dataset .wayfarerOpenActionView || 'actions' ); return; } const closer = event.target.closest( '[data-wayfarer-close-action-view]' ); if ( !closer || !actionScreen.contains( closer ) ) { return; } event.preventDefault(); const returnView = closer.dataset .wayfarerReturnActionView || 'actions'; setActionView( returnView ); let focusTarget = null; if (returnView === 'actions') { focusTarget = actionList ? actionList.querySelector( 'button:not([disabled]), a[href]' ) : null; } else { const parentPanel = actionScreen.querySelector( '[data-wayfarer-action-view="' + returnView + '"]' ); focusTarget = parentPanel ? parentPanel.querySelector( '[data-wayfarer-open-action-view], button:not([disabled]), a[href]' ) : null; } if ( focusTarget && typeof focusTarget.focus === 'function' ) { focusTarget.focus(); } } ); } /* WAYFARER_STORYLET_BRANCH_SELECTION_V59 */ document.addEventListener( 'click', function (event) { const branch = event.target.closest( '[data-wayfarer-storylet-branch-id]' ); if (!branch) { return; } if ( !presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( branch.dataset .wayfarerStoryletBranchId, 'branch', branch.dataset .wayfarerStoryletParentId ); } ); if (presentationContinueButton) { presentationContinueButton.addEventListener( 'click', dismissPresentation ); } document.addEventListener( 'keydown', function (event) { if ( !presentationActive || presentationRequestInFlight || storyletRequestInFlight || !presentationContinueButton ) { return; } const target = event.target; if ( target && target.matches && target.matches( 'input, textarea, select, button, a[href], [contenteditable="true"]' ) ) { return; } if ( event.key !== 'Enter' && event.key !== ' ' ) { return; } event.preventDefault(); dismissPresentation(); } ); window.addEventListener( 'pagehide', function () { running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } } ); window.wayfarerRuntimeController = { start: startRuntime, pause: pauseRuntime, dismissPresentation: dismissPresentation, enterStorylet: requestStorylet, isRunning: function () { return running; }, mode: function () { if (presentationActive) { return 'presentation'; } return running ? 'running' : 'paused'; } }; setActionView( 'actions' ); renderControls(); emitModeEvent(); })(); /* |-------------------------------------------------------------------------- | WAYFARER PLAY SHELL v20 |-------------------------------------------------------------------------- | | Reorganizes existing server-rendered nodes into the viewport shell. | Existing nodes are MOVED, not cloned/replaced, so the tick-control | event listeners installed above remain intact. | */ (() => { 'use strict'; if ( document.getElementById( 'wayfarer-play-shell' ) ) { return; } function exactHeading( level, text ) { const headings = document.querySelectorAll( level ); for (const heading of headings) { if ( heading.textContent .replace(/\s+/g, ' ') .trim() === text ) { return heading; } } return null; } function moveRange( start, stopExclusive, destination ) { if ( !start || !destination ) { return; } let node = start; while ( node && node !== stopExclusive ) { const next = node.nextSibling; destination.appendChild( node ); node = next; } } const body = document.body; /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * PHP decides whether Author Mode is permitted. JavaScript only shapes the * interface around that server-side decision. */ const canUseAuthorMode = body.dataset.wayfarerAuthorMode === '1'; body.classList.add( 'wayfarer-play-page' ); const shell = document.createElement( 'div' ); shell.id = 'wayfarer-play-shell'; shell.className = 'wayfarer-play-shell'; const header = document.createElement( 'header' ); header.className = 'wayfarer-play-shell-header'; const title = document.createElement( 'div' ); title.className = 'wayfarer-play-title'; const actions = document.createElement( 'div' ); actions.className = 'wayfarer-shell-actions'; const toggleAuthor = document.createElement( 'button' ); toggleAuthor.type = 'button'; toggleAuthor.className = 'wayfarer-shell-button'; if (canUseAuthorMode) { actions.appendChild( toggleAuthor ); } header.appendChild( title ); if (canUseAuthorMode) { header.appendChild( actions ); } const main = document.createElement( 'main' ); main.className = 'wayfarer-play-main'; const playerPane = document.createElement( 'section' ); playerPane.className = 'wayfarer-player-pane'; const authorPane = document.createElement( 'aside' ); authorPane.className = 'wayfarer-author-pane'; const authorHeader = document.createElement( 'div' ); authorHeader.className = 'wayfarer-author-header'; const authorToolbar = document.createElement( 'div' ); authorToolbar.className = 'wayfarer-author-toolbar'; const tabBar = document.createElement( 'div' ); tabBar.className = 'wayfarer-author-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const authorContent = document.createElement( 'div' ); authorContent.className = 'wayfarer-author-content'; authorPane.appendChild( authorHeader ); authorPane.appendChild( authorToolbar ); authorPane.appendChild( tabBar ); authorPane.appendChild( authorContent ); main.appendChild( playerPane ); if (canUseAuthorMode) { main.appendChild( authorPane ); } else { /* * Keep the player workspace full-width when Author Mode is unavailable. * No Author pane was rendered by PHP and none is attached here. */ shell.classList.add( 'author-collapsed' ); } shell.appendChild( header ); shell.appendChild( main ); body.insertBefore( shell, body.firstChild ); // ------------------------------------------------------------ // Top title/player identity // ------------------------------------------------------------ const pageTitle = Array.from( body.querySelectorAll( ':scope > h1' ) )[0]; if (pageTitle) { const playerIdentity = pageTitle.nextElementSibling; title.appendChild( pageTitle ); if ( playerIdentity && playerIdentity.tagName === 'P' ) { title.appendChild( playerIdentity ); } } // ------------------------------------------------------------ // Player content // ------------------------------------------------------------ /* * WAYFARER_PLAYER_START_SCREEN_V36 * * Before runtime state exists, the start screen owns the full player * workspace and Author Mode is intentionally hidden. */ const startScreen = document.querySelector( '[data-wayfarer-start-screen]' ); if (startScreen) { shell.classList.add( 'is-starting' ); playerPane.appendChild( startScreen ); } const scene = document.getElementById( 'wayfarer-player-scene' ); const gameStateHeading = exactHeading( 'h2', 'Game State' ) || exactHeading( 'h2', 'Game Not Initialized' ); if (gameStateHeading) { const meta = document.createElement( 'div' ); meta.className = 'wayfarer-player-meta'; /* * Move from Game State through the node immediately before the * player scene. This includes initialization messaging/forms but * stops before the actual scene. */ moveRange( gameStateHeading, scene, meta ); playerPane.appendChild( meta ); } if (scene) { playerPane.appendChild( scene ); } /* * WAYFARER_ACTION_SCREEN_V39 * * Action Mode is player-facing and exists independently of Author Mode. */ const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); if (actionScreen) { playerPane.appendChild( actionScreen ); } /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); if (presentationScreen) { playerPane.appendChild( presentationScreen ); } const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); if (travelControls) { playerPane.appendChild( travelControls ); } // ------------------------------------------------------------ // Author header // ------------------------------------------------------------ const authorHeading = exactHeading( 'h2', 'Author Mode' ); if (authorHeading) { const builderLinkBlock = authorHeading.nextElementSibling; authorHeader.appendChild( authorHeading ); if ( builderLinkBlock && builderLinkBlock.tagName === 'P' ) { authorHeader.appendChild( builderLinkBlock ); } } // ------------------------------------------------------------ // Runtime controls // ------------------------------------------------------------ const tickPanel = document.getElementById( 'wayfarer-tick-test' ); if (tickPanel) { authorToolbar.appendChild( tickPanel ); } // ------------------------------------------------------------ // Author tabs // ------------------------------------------------------------ /* WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 */ const panelDefinitions = [ { key: 'state', label: 'State' }, { key: 'debug', label: 'Debug' } ]; function activateTab(key) { document .querySelectorAll( '.wayfarer-author-tab' ) .forEach( function (button) { const active = button.dataset .wayfarerAuthorTab === key; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); authorContent .querySelectorAll( '.wayfarer-author-tab-panel' ) .forEach( function (panel) { panel.classList.toggle( 'is-active', panel.dataset .wayfarerAuthorPanel === key ); } ); try { window.localStorage.setItem( 'wayfarerAuthorTab', key ); } catch (error) { // Storage is optional. } } for (const definition of panelDefinitions) { const panel = document.querySelector( '[data-wayfarer-author-panel="' + definition.key + '"]' ); if (!panel) { continue; } authorContent.appendChild( panel ); const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-author-tab'; button.dataset.wayfarerAuthorTab = definition.key; button.setAttribute( 'role', 'tab' ); button.textContent = definition.label; button.addEventListener( 'click', function () { activateTab( definition.key ); } ); tabBar.appendChild( button ); } let initialTab = 'state'; try { let savedTab = window.localStorage.getItem( 'wayfarerAuthorTab' ); if ( savedTab === 'region' ) { savedTab = 'state'; window.localStorage.setItem( 'wayfarerAuthorTab', 'state' ); } if ( savedTab && authorContent.querySelector( '[data-wayfarer-author-panel="' + savedTab + '"]' ) ) { initialTab = savedTab; } } catch (error) { // Storage is optional. } activateTab( initialTab ); // ------------------------------------------------------------ // Hide / Show Author Mode // WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 // ------------------------------------------------------------ if (canUseAuthorMode) { function renderAuthorToggle() { const collapsed = shell.classList.contains( 'author-collapsed' ); toggleAuthor.textContent = collapsed ? 'Show Author' : 'Hide Author'; toggleAuthor.setAttribute( 'aria-expanded', collapsed ? 'false' : 'true' ); } toggleAuthor.addEventListener( 'click', function () { shell.classList.toggle( 'author-collapsed' ); try { window.localStorage.setItem( 'wayfarerAuthorCollapsed', shell.classList.contains( 'author-collapsed' ) ? '1' : '0' ); } catch (error) { // Storage is optional. } renderAuthorToggle(); } ); try { if ( window.localStorage.getItem( 'wayfarerAuthorCollapsed' ) === '1' ) { shell.classList.add( 'author-collapsed' ); } } catch (error) { // Storage is optional. } renderAuthorToggle(); } else { /* * Player role is not merely a hidden Author interface. Keep the shell * permanently in player-only layout. */ shell.classList.add( 'author-collapsed' ); } // ------------------------------------------------------------ // Remove otherwise-empty top-level HRs left by the old document flow. // ------------------------------------------------------------ body .querySelectorAll( ':scope > hr' ) .forEach( function (rule) { rule.remove(); } ); })(); ================================================================================ FILE: update-backups/v63-20260925-234422/play/includes/ending-screen.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT relative_path FROM wf_media WHERE id = ? AND game_id = ? AND media_type = 'image' LIMIT 1 " ); $stmt->execute([ $imageMediaId, (int)$gameId, ]); $relativePath = $stmt->fetchColumn(); if ($relativePath !== false) { $relativePath = ltrim( str_replace( '\\', '/', (string)$relativePath ), '/' ); if ( $relativePath !== '' && strpos( $relativePath, '..' ) === false ) { $endingImageUrl = '../' . $relativePath; } } } ?> ================================================================================ FILE: update-backups/v63-20260925-234422/play/includes/runtime-bootstrap.php ================================================================================ ================================================================================ FILE: update-backups/v63-20260925-234422/play/index.php ================================================================================ prepare(" SELECT g.id AS game_id, g.name AS game_name, g.slug AS game_slug, g.instructions AS game_instructions, g.player_slots AS player_slots, g.config AS game_config, gu.id AS game_user_id, gu.role AS game_user_role, gu.state, u.username, u.display_name FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id INNER JOIN wf_users u ON u.id = gu.user_id WHERE gu.game_id = ? AND gu.user_id = ? LIMIT 1 "); $stmt->execute([ $gameId, $userId ]); $gameUser = $stmt->fetch(); if (!$gameUser) { die('Game user not found.'); } /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * Author permission belongs to the user/game relationship, not to the user * globally. wf_game_users.role is therefore the canonical source. */ $canUseAuthorMode = strtolower( trim( (string)( $gameUser['game_user_role'] ?? '' ) ) ) === 'author'; // ================================================================ // RUNTIME STATE // ================================================================ $state = null; if (!empty($gameUser['state'])) { $decodedState = json_decode( $gameUser['state'], true ); if (is_array($decodedState)) { $state = $decodedState; } } /* * WAYFARER_ENTITY_RUNTIME_STATE_PAGE_RECOVERY_V49 * * Existing development playthroughs should gain Entity state immediately on * page load. This is additive and does not overwrite current runtime values. */ if ($state !== null) { $runtimeRecoveryChanged = wayfarerRuntimeEnsureEntityState( $pdo, (int)$gameId, $state ); /* WAYFARER_PLAYER_TRAITS_PAGE_RECOVERY_V52 */ if ( wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$gameId, $state ) ) { $runtimeRecoveryChanged = true; } if ($runtimeRecoveryChanged) { $encodedRecoveredState = wayfarerRuntimeEncodeState( $state ); $entityRecoveryStmt = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $entityRecoveryStmt->execute([ $encodedRecoveredState, (int)$gameUser['game_user_id'], ]); /* * Keep Author Debug and any later page component on the same snapshot. */ $gameUser['state'] = $encodedRecoveredState; } } // ================================================================ // INITIALIZE GAME // ================================================================ $initializationError = ''; if ( $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'initialize' && $state === null ) { try { wayfarerRuntimeInitializeGame( $pdo, (int)$gameUser['game_user_id'], [ 'player_names' => $_POST['player_names'] ?? [], 'choices' => $_POST['setup_choices'] ?? [], ] ); /* * Reload from persisted state so every play-page component sees the * exact same initialized snapshot. */ header('Location: index.php'); exit; } catch (Throwable $exception) { $initializationError = $exception->getMessage(); } } // ================================================================ // AUTHOR MODE: GAME TRAIT DEFINITIONS // ================================================================ $gameTraitDefinitions = []; $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } foreach ($gameConfig['traits'] ?? [] as $trait) { if (empty($trait['id'])) { continue; } $gameTraitDefinitions[$trait['id']] = $trait; } ?> <?= htmlspecialchars($gameUser['game_name']) ?>

Player:



================================================================================ FILE: update-backups/v64-20260926-011117/includes/builder/editor.php ================================================================================ $groupValues) { if (!is_array($groupValues)) { continue; } foreach ($groupValues as $traitName => $traitValue) { if (!isset($storedSystemTraitValues[$groupName][$traitName])) { $storedSystemTraitValues[$groupName][$traitName] = $traitValue; } } } } } $renderSystemTraits = function () use ( $record, $fields, $systemTraitsSelector, $systemTraitGroups, $storedSystemTraitValues, $storedSystemTraitMaxValues ) { if (!$systemTraitsSelector || empty($systemTraitGroups)) { return; } $selectedGroup = $record[$systemTraitsSelector] ?? ($fields[$systemTraitsSelector]['default'] ?? ''); ?>
$groupDefinition): ?>
>

$traitDefinition): ?>

>

$relationship): ?> $field): ?>

Media

Optional. Reuse an existing asset from this game's Media Library, upload a new asset, or leave the slot empty.

$mediaDefinition): ?>

Maximum upload: .

Current: ()



No existing assets have been uploaded to this game yet.

$field): ?>



Traits

Traits are additional values associated with this that can change during play.


Danger Zone

Permanently delete this . This cannot be undone.

================================================================================ FILE: update-backups/v64-20260926-011117/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v64-20260926-011117/play/assets/play.css ================================================================================ /* |-------------------------------------------------------------------------- | Wayfarer Play Shell v20 |-------------------------------------------------------------------------- */ html, body { height: 100%; } body.wayfarer-play-page { margin: 0; overflow: hidden; background: #eef1f5; color: #20242a; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.45; } .wayfarer-play-page * { box-sizing: border-box; } .wayfarer-play-page hr { display: none; } .wayfarer-play-shell { height: 100vh; display: flex; flex-direction: column; overflow: hidden; } .wayfarer-play-shell-header { flex: 0 0 auto; min-height: 68px; padding: 12px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; background: #ffffff; border-bottom: 1px solid #d8dbe2; } .wayfarer-play-title { min-width: 0; } .wayfarer-play-title h1 { margin: 0; font-size: 1.35rem; line-height: 1.2; } .wayfarer-play-title p { margin: 4px 0 0; color: #667085; font-size: 0.9rem; } .wayfarer-shell-actions { display: flex; align-items: center; gap: 8px; } .wayfarer-shell-button { border: 1px solid #b8bdc7; border-radius: 6px; padding: 7px 10px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 600; } .wayfarer-shell-button:hover { background: #f6f7f9; } .wayfarer-play-main { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 7fr) minmax(330px, 3fr); gap: 12px; padding: 12px; overflow: hidden; } .wayfarer-player-pane, .wayfarer-author-pane { min-width: 0; min-height: 0; background: #ffffff; border: 1px solid #d8dbe2; border-radius: 10px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); } .wayfarer-player-pane { overflow: auto; padding: 22px; } .wayfarer-player-pane > h2:first-child { margin-top: 0; } #wayfarer-player-scene { margin-top: 10px; } #wayfarer-player-scene h2 { margin-top: 0; } .wayfarer-author-pane { display: flex; flex-direction: column; overflow: hidden; } .wayfarer-author-header { flex: 0 0 auto; padding: 14px 16px 10px; border-bottom: 1px solid #e3e6eb; } .wayfarer-author-header h2 { margin: 0; font-size: 1.05rem; } .wayfarer-author-header p { margin: 6px 0 0; font-size: 0.85rem; } .wayfarer-author-header a { color: #2f5ea8; } .wayfarer-author-toolbar { flex: 0 0 auto; padding: 10px 14px; background: #f6f7f9; border-bottom: 1px solid #e3e6eb; } .wayfarer-author-toolbar #wayfarer-tick-test { margin: 0; } .wayfarer-author-toolbar #wayfarer-tick-test > h2 { margin: 0 0 6px; font-size: 0.9rem; } .wayfarer-author-toolbar #wayfarer-tick-test > p { margin: 5px 0; font-size: 0.82rem; } .wayfarer-author-toolbar button { margin-right: 4px; margin-bottom: 2px; padding: 5px 8px; } #wayfarer-tick-message { min-height: 1.2em; } .wayfarer-author-tabs { flex: 0 0 auto; display: flex; gap: 2px; padding: 8px 10px 0; background: #ffffff; border-bottom: 1px solid #d8dbe2; } .wayfarer-author-tab { appearance: none; border: 0; border-bottom: 2px solid transparent; padding: 8px 10px 7px; background: transparent; color: #667085; cursor: pointer; font: inherit; font-size: 0.86rem; font-weight: 600; } .wayfarer-author-tab:hover { color: #20242a; } .wayfarer-author-tab.is-active { color: #2f5ea8; border-bottom-color: #2f5ea8; } .wayfarer-author-content { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 14px 16px 18px; } .wayfarer-author-tab-panel { display: none; } .wayfarer-author-tab-panel.is-active { display: block; } .wayfarer-author-tab-panel > :first-child { margin-top: 0; } .wayfarer-author-tab-panel h3 { margin-top: 0; font-size: 1rem; } .wayfarer-author-tab-panel h4 { margin-bottom: 8px; } .wayfarer-author-tab-panel table { width: 100%; border-collapse: collapse; font-size: 0.84rem; } .wayfarer-author-tab-panel th, .wayfarer-author-tab-panel td { padding: 6px 7px; text-align: left; vertical-align: top; } .wayfarer-author-tab-panel pre { max-width: 100%; overflow: auto; font-size: 0.78rem; } #wayfarer-author-debug-panel details { margin-top: 0 !important; } #wayfarer-author-debug-panel summary { display: none; } #wayfarer-author-debug-panel details:not([open]) > *:not(summary) { display: block; } #wayfarer-author-debug-panel details > pre { display: block; max-height: none !important; margin-top: 0 !important; } .wayfarer-player-meta { margin-bottom: 14px; padding: 10px 12px; border-radius: 7px; background: #f6f7f9; color: #667085; font-size: 0.86rem; } .wayfarer-player-meta h2 { margin: 0 0 4px; color: #20242a; font-size: 0.95rem; } .wayfarer-player-meta p { margin: 0; } .wayfarer-play-shell.author-collapsed .wayfarer-play-main { grid-template-columns: minmax(0, 1fr); } .wayfarer-play-shell.author-collapsed .wayfarer-author-pane { display: none; } @media (max-width: 900px) { body.wayfarer-play-page { overflow: auto; } .wayfarer-play-shell { height: auto; min-height: 100vh; overflow: visible; } .wayfarer-play-main { display: block; overflow: visible; padding: 8px; } .wayfarer-player-pane, .wayfarer-author-pane { overflow: visible; margin-bottom: 10px; } .wayfarer-author-pane { min-height: 480px; } .wayfarer-author-content { overflow: visible; } .wayfarer-play-shell.author-collapsed .wayfarer-author-pane { display: none; } } /* ------------------------------------------------------------------ GAME START SCREEN WAYFARER_PLAYER_START_SCREEN_V36 ------------------------------------------------------------------ */ /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * The start screen no longer implies "no Author Mode." Authors may inspect * the Author panel before initialization. The existing author-collapsed class * controls the one-column layout for normal players and collapsed authors. */ .wayfarer-start-screen { width: min(860px, 100%); margin: 0 auto; } .wayfarer-start-screen-heading { margin-bottom: 24px; } .wayfarer-start-screen-heading h2 { margin: 0 0 12px; font-size: 1.65rem; } .wayfarer-start-introduction { max-width: 72ch; color: #4b5563; line-height: 1.65; } .wayfarer-start-error { margin-bottom: 18px; padding: 10px 12px; border: 1px solid #e4a7ad; border-radius: 7px; background: #fff4f5; color: #8b1e2d; } .wayfarer-start-form { display: grid; gap: 22px; } .wayfarer-start-section, .wayfarer-start-choice { margin: 0; padding: 18px; border: 1px solid #d8dbe2; border-radius: 9px; background: #ffffff; } .wayfarer-start-section h3, .wayfarer-start-choice legend { margin: 0 0 14px; color: #20242a; font-size: 1.05rem; font-weight: 700; } .wayfarer-start-choice legend { padding: 0 6px; } .wayfarer-start-names { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } .wayfarer-start-names label { display: grid; gap: 5px; font-weight: 600; } .wayfarer-start-names input { width: 100%; padding: 9px 10px; border: 1px solid #b8bdc7; border-radius: 6px; font: inherit; } .wayfarer-start-choice { display: grid; gap: 10px; } .wayfarer-start-option { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: start; padding: 12px 13px; border: 1px solid #d8dbe2; border-radius: 7px; cursor: pointer; } .wayfarer-start-option:hover { background: #f6f7f9; } .wayfarer-start-option input { margin-top: 4px; } .wayfarer-start-option-copy { display: grid; gap: 4px; } .wayfarer-start-option-copy > span { color: #667085; font-size: 0.92rem; } .wayfarer-start-actions { display: flex; justify-content: flex-end; } .wayfarer-start-button { padding: 10px 18px; border: 1px solid #244b88; border-radius: 7px; background: #2f5ea8; color: #ffffff; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-start-button:hover { background: #244b88; } @media (max-width: 700px) { .wayfarer-start-screen { width: 100%; } .wayfarer-start-section, .wayfarer-start-choice { padding: 14px; } } /* ------------------------------------------------------------------ PLAYER ACTION SCREEN WAYFARER_ACTION_SCREEN_V39 ------------------------------------------------------------------ */ .wayfarer-action-screen { margin-top: 14px; padding: 18px; border: 1px solid #d8dbe2; border-radius: 10px; background: #ffffff; box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); } .wayfarer-action-screen[hidden], .wayfarer-travel-controls[hidden] { display: none !important; } .wayfarer-action-heading { margin-bottom: 14px; } .wayfarer-action-heading h2 { margin: 0 0 4px; font-size: 1.25rem; } .wayfarer-action-heading p { margin: 0; color: #667085; } .wayfarer-action-list { display: grid; gap: 10px; } .wayfarer-action-button { width: 100%; padding: 12px 14px; border: 1px solid #214b86; border-radius: 8px; background: #245a9b; color: #ffffff; cursor: pointer; font: inherit; font-weight: 700; text-align: left; } .wayfarer-action-button:hover { background: #1e4c84; } .wayfarer-action-button:disabled { cursor: default; opacity: 0.55; } /* ------------------------------------------------------------------ IN-CARD ACTION UTILITY VIEWS WAYFARER_ACTION_UTILITY_VIEWS_V48 ------------------------------------------------------------------ */ .wayfarer-action-utility-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 2px; } .wayfarer-action-utility-button, .wayfarer-action-back-button { padding: 10px 12px; border: 1px solid #b8bdc7; border-radius: 7px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 650; } .wayfarer-action-utility-button { width: 100%; text-align: center; } .wayfarer-action-utility-button:hover, .wayfarer-action-back-button:hover { border-color: #98a2b3; background: #f6f7f9; } .wayfarer-action-subview[hidden], .wayfarer-action-heading[hidden], .wayfarer-action-list[hidden] { display: none !important; } .wayfarer-action-subview-heading { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 18px; } .wayfarer-action-subview-heading h2 { margin: 0; font-size: 1.25rem; } .wayfarer-action-back-button { flex: 0 0 auto; } .wayfarer-action-subview-body { min-height: 90px; } /* ------------------------------------------------------------------ ENTITY INVENTORY / STATUS PRESENTATION WAYFARER_ENTITY_PRESENTATION_V49 ------------------------------------------------------------------ */ .wayfarer-inventory-list, .wayfarer-party-list { display: grid; gap: 10px; } .wayfarer-inventory-item, .wayfarer-party-member, .wayfarer-status-section { padding: 13px 14px; border: 1px solid #d8dbe2; border-radius: 8px; background: #f8fafc; } .wayfarer-inventory-item-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; } .wayfarer-inventory-quantity { flex: 0 0 auto; color: #667085; font-size: 0.9rem; font-weight: 650; } .wayfarer-status-section + .wayfarer-status-section { margin-top: 12px; } .wayfarer-status-section > h3 { margin: 0 0 10px; font-size: 1rem; } .wayfarer-status-player-name { margin: 0; font-weight: 700; } .wayfarer-party-member { background: #ffffff; } .wayfarer-party-member h4 { margin: 0; font-size: 0.98rem; } /* WAYFARER_PLAYER_TRAITS_STATUS_V52 */ .wayfarer-player-member h4::after { content: "Player"; margin-left: 8px; color: #667085; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; } .wayfarer-status-values { margin: 10px 0 0; } .wayfarer-status-values > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; padding: 6px 0; border-top: 1px solid #e3e6eb; } .wayfarer-status-values dt { color: #4b5563; } .wayfarer-status-values dd { margin: 0; font-weight: 700; text-align: right; } .wayfarer-status-muted { margin: 8px 0 0; color: #667085; font-size: 0.88rem; } /* ------------------------------------------------------------------ ENTITY DETAIL VIEW WAYFARER_ENTITY_DETAIL_VIEW_V50 ------------------------------------------------------------------ */ .wayfarer-entity-open-button { appearance: none; border: 0; padding: 0; background: transparent; color: #2f5ea8; cursor: pointer; font: inherit; font-weight: 750; text-align: left; } .wayfarer-entity-open-button:hover { text-decoration: underline; } .wayfarer-entity-open-button:focus-visible { outline: 2px solid #2f5ea8; outline-offset: 3px; border-radius: 2px; } .wayfarer-status-player-names { display: grid; gap: 4px; } .wayfarer-status-player-names .wayfarer-status-player-name { margin: 0; } .wayfarer-entity-detail-eyebrow { margin: 0 0 3px; color: #667085; font-size: 0.78rem; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; } .wayfarer-entity-detail-layout { display: grid; grid-template-columns: minmax(180px, 280px) minmax(0, 1fr); gap: 22px; align-items: start; } .wayfarer-entity-detail-layout:not(:has(.wayfarer-entity-detail-media)) { grid-template-columns: minmax(0, 1fr); } .wayfarer-entity-detail-media { margin: 0; } .wayfarer-entity-detail-media img { display: block; width: 100%; max-height: 340px; object-fit: contain; border-radius: 8px; background: #f6f7f9; } .wayfarer-entity-detail-description { margin: 0 0 16px; line-height: 1.6; } .wayfarer-entity-detail-copy > :first-child { margin-top: 0; } @media (max-width: 700px) { .wayfarer-entity-detail-layout { grid-template-columns: 1fr; } .wayfarer-entity-detail-media img { max-height: 280px; } } .wayfarer-action-empty { margin: 0; color: #667085; font-size: 0.92rem; } .wayfarer-travel-controls { margin-top: 14px; padding: 12px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid #d8dbe2; border-radius: 10px; background: #ffffff; } .wayfarer-travel-status { display: flex; align-items: center; gap: 9px; font-weight: 700; } /* WAYFARER_TRAVEL_TIME_HUD_V61 */ .wayfarer-travel-copy { min-width: 0; } .wayfarer-travel-primary { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px; } .wayfarer-travel-time { font-weight: 800; } .wayfarer-travel-time-separator { color: #98a2b3; } .wayfarer-travel-next { margin-top: 3px; color: #667085; font-size: 0.88rem; font-weight: 600; } .wayfarer-travel-indicator { width: 9px; height: 9px; border-radius: 999px; background: currentColor; } .wayfarer-travel-pause { padding: 8px 12px; border: 1px solid #aab4c3; border-radius: 7px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 600; } .wayfarer-travel-pause:hover { background: #f6f7f9; } @media (max-width: 620px) { .wayfarer-action-screen { padding: 14px; } .wayfarer-travel-controls { align-items: stretch; flex-direction: column; } .wayfarer-travel-pause { width: 100%; } .wayfarer-action-utility-row { grid-template-columns: 1fr; } } /* ------------------------------------------------------------------ LOCATION / STORY PRESENTATION WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 ------------------------------------------------------------------ */ /* * Scene prose now belongs to Presentation Mode rather than the normal Action * page. The server-rendered scene remains in the DOM temporarily for runtime * compatibility but is not player-facing. */ #wayfarer-player-scene { display: none; } .wayfarer-action-parent-context { margin-bottom: 4px !important; font-size: 0.85rem; font-weight: 600; } /* ------------------------------------------------------------------ STORYLET ACTIONS / BRANCHES WAYFARER_STORYLET_ACTION_RUNTIME_V59 ------------------------------------------------------------------ */ .wayfarer-storylet-action-list { display: grid; gap: 10px; } .wayfarer-storylet-action-list:empty { display: none; } .wayfarer-storylet-branch-list { width: 100%; display: grid; gap: 10px; } .wayfarer-presentation-actions.wayfarer-has-storylet-branches > div:not(.wayfarer-storylet-branch-list) { display: none; } .wayfarer-storylet-branch-prompt { margin: 0 0 4px; color: #b8c0cc; font-weight: 700; } .wayfarer-storylet-branch-button { width: 100%; padding: 12px 14px; border: 1px solid #f8fafc; border-radius: 8px; background: transparent; color: #f8fafc; cursor: pointer; font: inherit; font-weight: 700; text-align: left; } .wayfarer-storylet-branch-button:hover, .wayfarer-storylet-branch-button:focus-visible { background: #f8fafc; color: #15181d; } .wayfarer-storylet-branch-button:disabled { cursor: default; opacity: 0.55; } .wayfarer-presentation-screen { position: fixed; z-index: 10000; inset: 0; overflow-y: auto; padding: clamp(24px, 6vh, 72px) 24px; background: #15181d; color: #f8fafc; } .wayfarer-presentation-screen[hidden] { display: none !important; } .wayfarer-presentation-inner { width: min(1040px, 100%); min-height: calc(100vh - clamp(48px, 12vh, 144px)); margin: 0 auto; display: flex; flex-direction: column; justify-content: center; } .wayfarer-presentation-media { width: 100%; margin: 0 0 26px; overflow: hidden; border-radius: 12px; background: #0d0f12; } .wayfarer-presentation-media img { display: block; width: 100%; max-height: 52vh; object-fit: cover; } .wayfarer-presentation-copy { width: min(760px, 100%); margin: 0 auto; } .wayfarer-presentation-eyebrow { margin: 0 0 8px; color: #b8c0cc; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .wayfarer-presentation-copy h1 { margin: 0 0 18px; color: inherit; font-size: clamp(2rem, 5vw, 3.8rem); line-height: 1.05; } .wayfarer-presentation-description { font-size: clamp(1rem, 2vw, 1.22rem); line-height: 1.7; } .wayfarer-presentation-actions { width: min(760px, 100%); margin: 32px auto 0; display: flex; justify-content: flex-end; } .wayfarer-presentation-continue { min-width: 150px; padding: 11px 18px; border: 1px solid #f8fafc; border-radius: 8px; background: #f8fafc; color: #15181d; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-presentation-continue:hover { background: #e8edf3; } .wayfarer-presentation-continue:focus-visible { outline: 3px solid #ffffff; outline-offset: 4px; } .wayfarer-presentation-key-hint { margin: 10px 0 0; color: #9ea8b6; font-size: 0.78rem; text-align: right; } body.wayfarer-runtime-presentation { overflow: hidden; } @media (max-width: 620px) { .wayfarer-presentation-screen { padding: 20px 14px; } .wayfarer-presentation-inner { min-height: calc(100vh - 40px); } .wayfarer-presentation-actions { justify-content: stretch; } .wayfarer-presentation-continue { width: 100%; } } /* ------------------------------------------------------------------ COMBINED AUTHOR STATE + REGION WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 ------------------------------------------------------------------ */ .wayfarer-author-state-region { margin-top: 20px; padding-top: 14px; border-top: 1px solid #e4e7ec; } .wayfarer-author-state-region > :first-child { margin-top: 0; } /* ------------------------------------------------------------------ TERMINAL GAME ENDING WAYFARER_GAME_ENDING_SCREEN_V45 ------------------------------------------------------------------ */ .wayfarer-ending-screen { position: fixed; z-index: 11000; inset: 0; overflow-y: auto; padding: clamp(24px, 6vh, 72px) 24px; background: #11151a; color: #f8fafc; } .wayfarer-ending-inner { width: min(1040px, 100%); min-height: calc(100vh - clamp(48px, 12vh, 144px)); margin: 0 auto; display: flex; flex-direction: column; justify-content: center; } .wayfarer-ending-media { width: 100%; margin: 0 0 28px; overflow: hidden; border-radius: 12px; background: #080a0d; } .wayfarer-ending-media img { display: block; width: 100%; max-height: 52vh; object-fit: cover; } .wayfarer-ending-copy { width: min(760px, 100%); margin: 0 auto; } .wayfarer-ending-eyebrow { margin: 0 0 8px; color: #aeb8c5; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .wayfarer-ending-copy h1 { margin: 0 0 18px; color: inherit; font-size: clamp(2rem, 5vw, 3.8rem); line-height: 1.05; } .wayfarer-ending-text { font-size: clamp(1rem, 2vw, 1.22rem); line-height: 1.7; } body.wayfarer-game-ended { overflow: hidden; } /* ------------------------------------------------------------------ PRESENTATION TRANSITION + ENDING RESTART WAYFARER_PRESENTATION_TRANSITION_V63 WAYFARER_ENDING_RESTART_V63 ------------------------------------------------------------------ */ body.wayfarer-presentation-transitioning::after { content: ""; position: fixed; z-index: 10500; inset: 0; background: #15181d; pointer-events: all; } .wayfarer-ending-actions { margin-top: 30px; display: flex; justify-content: flex-end; } .wayfarer-ending-restart { min-width: 150px; padding: 11px 18px; border: 1px solid #f8fafc; border-radius: 8px; background: #f8fafc; color: #15181d; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-ending-restart:hover { background: #e8edf3; } .wayfarer-ending-restart:focus-visible { outline: 3px solid #ffffff; outline-offset: 4px; } .wayfarer-ending-restart:disabled { cursor: default; opacity: 0.6; } @media (max-width: 620px) { .wayfarer-ending-actions { justify-content: stretch; } .wayfarer-ending-restart { width: 100%; } } ================================================================================ FILE: update-backups/v64-20260926-011117/play/assets/play.js ================================================================================ /* |-------------------------------------------------------------------------- | PLAYER RUNTIME CONTROLLER | WAYFARER_ACTION_SCREEN_V39 | WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 |-------------------------------------------------------------------------- | | Runtime modes currently exposed to the player: | | presentation -> Action -> running navigation -> presentation/Action | | Presentation is persisted by PHP. Automatic tick advancement cannot continue | while a presentation is active. | */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; if (!config) { return; } const body = document.body; const tickValue = document.getElementById( 'wayfarer-tick-value' ); const statusValue = document.getElementById( 'wayfarer-tick-status' ); const message = document.getElementById( 'wayfarer-tick-message' ); const onceButton = document.getElementById( 'wayfarer-tick-once' ); const autoButton = document.getElementById( 'wayfarer-tick-auto' ); const authorPauseButton = document.getElementById( 'wayfarer-tick-pause' ); const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); /* WAYFARER_TRAVEL_TIME_HUD_V61 */ const travelTime = document.querySelector( '[data-wayfarer-travel-time]' ); const travelTimeSeparator = document.querySelector( '[data-wayfarer-travel-time-separator]' ); const travelNext = document.querySelector( '[data-wayfarer-travel-next]' ); const continueTravelButton = document.querySelector( '[data-wayfarer-continue-travel]' ); const playerPauseButton = document.querySelector( '[data-wayfarer-pause-travel]' ); /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Action Mode owns one physical card. Inventory, Status, and future * utility/context views temporarily replace the normal action heading/list * inside that card without becoming persisted runtime modes. */ const actionHeading = actionScreen ? actionScreen.querySelector( '.wayfarer-action-heading' ) : null; const actionList = actionScreen ? actionScreen.querySelector( '[data-wayfarer-action-list]' ) : null; const actionSubviewPanels = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ) : []; const actionViewOpeners = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-open-action-view]' ) ) : []; const actionViewClosers = actionScreen ? Array.from( actionScreen.querySelectorAll( '[data-wayfarer-close-action-view]' ) ) : []; /* * Persisted narrative presentation. */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); const presentationContinueButton = presentationScreen ? presentationScreen.querySelector( '[data-wayfarer-presentation-continue]' ) : null; let running = false; let requestInFlight = false; let presentationRequestInFlight = false; let storyletRequestInFlight = false; let timeoutId = null; let reloadWhenIdle = false; let presentationActive = Boolean( presentationScreen && !presentationScreen.hidden ); function setMessage( text, isError = false ) { if (!message) { return; } message.textContent = text; message.style.color = isError ? 'red' : ''; } function emitModeEvent() { let mode = 'paused'; if (presentationActive) { mode = 'presentation'; } else if (running) { mode = 'running'; } window.dispatchEvent( new CustomEvent( 'wayfarer:runtime-mode', { detail: { mode: mode } } ) ); /* * Keep the v39 event names available for existing development hooks. */ window.dispatchEvent( new CustomEvent( running ? 'wayfarer:runtime-running' : 'wayfarer:runtime-paused', { detail: { mode: mode } } ) ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 * * Generic in-card view switcher. Future Map / Shop / other views can use * the same data-wayfarer-open-action-view + data-wayfarer-action-view * contract without adding another page-level layout system. */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Resolve available views at the moment navigation occurs instead of * relying on the V48 page-load snapshot. This supports nested Entity * detail screens and future context views added to the same Action card. */ function setActionView(viewName = 'actions') { if (!actionScreen) { return; } const requested = String( viewName || 'actions' ); const showActions = requested === 'actions'; const panels = Array.from( actionScreen.querySelectorAll( '[data-wayfarer-action-view]' ) ); let matched = showActions; panels.forEach( function (panel) { const active = panel.dataset .wayfarerActionView === requested; panel.hidden = !active; if (active) { matched = true; } } ); /* * Fail safely if a malformed/removed view name is requested. */ if (!matched) { panels.forEach( function (panel) { panel.hidden = true; } ); if (actionHeading) { actionHeading.hidden = false; } if (actionList) { actionList.hidden = false; } return; } if (actionHeading) { actionHeading.hidden = !showActions; } if (actionList) { actionList.hidden = !showActions; } } function renderControls() { if (statusValue) { statusValue.textContent = presentationActive ? 'Presentation' : ( running ? 'Running' : 'Paused' ); } if (onceButton) { onceButton.disabled = running || requestInFlight || presentationActive; } if (autoButton) { autoButton.disabled = running || requestInFlight || presentationActive; } if (authorPauseButton) { authorPauseButton.disabled = !running; } if (continueTravelButton) { continueTravelButton.disabled = running || requestInFlight || presentationActive; } document .querySelectorAll( '[data-wayfarer-storylet-action-id], ' + '[data-wayfarer-storylet-branch-id]' ) .forEach( function (button) { button.disabled = requestInFlight || presentationRequestInFlight || storyletRequestInFlight || ( button.hasAttribute( 'data-wayfarer-storylet-action-id' ) && (running || presentationActive) ); } ); if (playerPauseButton) { playerPauseButton.disabled = !running; } if (actionScreen) { actionScreen.hidden = running || presentationActive; } if (travelControls) { travelControls.hidden = !running || presentationActive; } if (presentationScreen) { presentationScreen.hidden = !presentationActive; presentationScreen.setAttribute( 'aria-hidden', presentationActive ? 'false' : 'true' ); } body.classList.toggle( 'wayfarer-runtime-running', running ); body.classList.toggle( 'wayfarer-runtime-paused', !running && !presentationActive ); body.classList.toggle( 'wayfarer-runtime-presentation', presentationActive ); } /* * WAYFARER_LIVE_ACTION_CONTEXT_V46_1 * * Action Mode must reflect the current runtime position, not the page's * original server-rendered context. */ function renderPlayerContext(context) { if ( !context || typeof context !== 'object' ) { return; } const heading = document.querySelector( '[data-wayfarer-action-context-name]' ); const parent = document.querySelector( '[data-wayfarer-action-parent-context]' ); if (heading) { const name = String( context.context_name || 'Actions' ).trim(); heading.textContent = name || 'Actions'; } if (parent) { const parentText = String( context.parent_context || '' ).trim(); parent.textContent = parentText; parent.hidden = parentText === ''; } } /* WAYFARER_TRAVEL_TIME_HUD_RENDER_V61 */ function renderTravelStatus(travel) { if (!travel || typeof travel !== 'object') { return; } const timeLabel = String(travel.time_label || '').trim(); if (travelTime) { travelTime.textContent = timeLabel; travelTime.hidden = timeLabel === ''; } if (travelTimeSeparator) { travelTimeSeparator.hidden = timeLabel === ''; } const nextLabel = String(travel.next_label || '').trim(); if (travelNext) { travelNext.textContent = nextLabel === '' ? '' : 'Next: ' + nextLabel; travelNext.hidden = nextLabel === ''; } } function displayRuntimeValue(value) { if (typeof value === 'boolean') { return value ? 'True' : 'False'; } if ( value === null || value === undefined ) { return 'null'; } if (typeof value === 'object') { return JSON.stringify( value ); } return String( value ); } /* * WAYFARER_STORYLET_ACTIONS_LIVE_V59 * * Tick/presentation APIs return the currently eligible root Manual * Storylets so Action Mode does not depend on the page-load snapshot. */ function renderStoryletActions(actions) { if (!actionScreen) { return; } const host = actionScreen.querySelector( '[data-wayfarer-storylet-actions]' ); if (!host) { return; } const normalized = Array.isArray(actions) ? actions : []; host.innerHTML = ''; normalized.forEach( function (action) { const id = Number( action && action.id ? action.id : 0 ); if (id <= 0) { return; } const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-action-button'; button.dataset.wayfarerStoryletActionId = String(id); button.textContent = String( action.label || action.name || 'Storylet' ); host.appendChild( button ); } ); const empty = actionScreen.querySelector( '[data-wayfarer-action-empty]' ); if (empty) { const canTravel = actionScreen.dataset.wayfarerCanTravel === '1'; empty.hidden = canTravel || host.children.length > 0; } renderControls(); } function renderRuntimeState(state) { if ( !state || typeof state !== 'object' ) { return; } if ( state.navigation && state.navigation.position !== undefined ) { const position = String( state.navigation.position ); const authorPosition = document.getElementById( 'wayfarer-author-position' ); if (authorPosition) { authorPosition.textContent = position; } } const gameTraits = state.game && state.game.traits && typeof state.game.traits === 'object' ? state.game.traits : {}; document .querySelectorAll( '[data-wayfarer-game-trait-value]' ) .forEach( function (cell) { const traitId = cell.dataset .wayfarerGameTraitValue; if ( Object.prototype .hasOwnProperty .call( gameTraits, traitId ) ) { cell.textContent = displayRuntimeValue( gameTraits[traitId] ); } } ); document .querySelectorAll( '[data-wayfarer-region-trait-value]' ) .forEach( function (cell) { const regionId = cell.dataset .wayfarerRegionId; const traitId = cell.dataset .wayfarerRegionTraitValue; const traits = state.regions && state.regions[regionId] && state.regions[regionId].traits && typeof state.regions[regionId].traits === 'object' ? state.regions[regionId].traits : null; if ( traits && Object.prototype .hasOwnProperty .call( traits, traitId ) ) { cell.textContent = displayRuntimeValue( traits[traitId] ); } } ); const debug = document.getElementById( 'wayfarer-runtime-debug' ); if (debug) { debug.textContent = JSON.stringify( state, null, 2 ); } } async function requestTick() { if ( requestInFlight || presentationActive ) { return null; } requestInFlight = true; renderControls(); try { const response = await fetch( config.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Tick endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Tick request failed.' ); } if (tickValue) { tickValue.textContent = String( data.tick ); } renderRuntimeState( data.state ); renderPlayerContext( data.context ); renderTravelStatus( data.travel ); renderStoryletActions( data.actions ); setMessage( 'Tick ' + data.tick + ' saved.' ); if (data.pause === true) { /* * Suppress Action Mode during the brief interval before reload. * The server-rendered presentation will exist after reload. */ presentationActive = true; pauseRuntime( data.pause_reason || 'Travel interrupted.', true ); } return data; } catch (error) { pauseRuntime( error instanceof Error ? error.message : 'Tick request failed.' ); setMessage( error instanceof Error ? error.message : 'Tick request failed.', true ); return null; } finally { requestInFlight = false; renderControls(); if (reloadWhenIdle) { reloadWhenIdle = false; window.location.reload(); } } } async function runAutomaticTick() { if ( !running || presentationActive ) { return; } const result = await requestTick(); if ( !running || presentationActive || result === null ) { return; } timeoutId = window.setTimeout( runAutomaticTick, Number( config.intervalMs ) || 1500 ); } function startRuntime() { if ( running || requestInFlight || presentationActive ) { return; } /* * Travel always leaves inspection/context views and returns the card to * its normal Action view for the next pause. */ setActionView( 'actions' ); running = true; setMessage( 'Automatic advancement started.' ); renderControls(); emitModeEvent(); runAutomaticTick(); } /* * WAYFARER_PRESENTATION_TRANSITION_V63 * * Cover the current DOM before a server-rendered Presentation replaces it. * This prevents the previous Location/Storylet copy from being painted for * a frame during the navigation boundary. */ function beginPresentationTransition() { body.classList.add( 'wayfarer-presentation-transitioning' ); } function pauseRuntime( reason = 'Automatic advancement paused.', reloadForPresentation = false ) { const wasRunning = running; running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } setMessage( reason ); if (wasRunning) { setActionView( 'actions' ); } renderControls(); if (wasRunning) { emitModeEvent(); } if (reloadForPresentation) { /* WAYFARER_PRESENTATION_TRANSITION_V63 */ beginPresentationTransition(); if (requestInFlight) { reloadWhenIdle = true; } else { window.location.reload(); } } } /* * WAYFARER_STORYLET_ACTION_RUNTIME_V59 */ async function requestStorylet( storyletId, entryMode = 'action', parentStoryletId = 0 ) { if ( storyletRequestInFlight || requestInFlight || presentationRequestInFlight ) { return; } storyletId = Number(storyletId || 0); parentStoryletId = Number(parentStoryletId || 0); if (storyletId <= 0) { return; } storyletRequestInFlight = true; renderControls(); try { const response = await fetch( config.storyletEndpoint || 'api/storylet.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, storylet_id: storyletId, entry_mode: entryMode, parent_storylet_id: parentStoryletId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Storylet endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not enter Storylet.' ); } /* * Storylet presentation/Branch markup is server-rendered. Reload * only after the runtime transaction succeeds, so Effects cannot be * repeated by a failed or duplicate browser transition. */ /* WAYFARER_PRESENTATION_TRANSITION_V63 */ beginPresentationTransition(); window.location.reload(); } catch (error) { window.alert( error instanceof Error ? error.message : 'Could not enter Storylet.' ); } finally { storyletRequestInFlight = false; renderControls(); } } async function dismissPresentation() { if ( !presentationActive || !presentationScreen || presentationRequestInFlight ) { return; } presentationRequestInFlight = true; if (presentationContinueButton) { presentationContinueButton.disabled = true; } const presentationType = presentationScreen.dataset .wayfarerPresentationType || ''; const presentationId = Number( presentationScreen.dataset .wayfarerPresentationId || 0 ); try { const response = await fetch( config.presentationEndpoint || 'api/presentation.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken, type: presentationType, id: presentationId }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Presentation endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not continue.' ); } presentationActive = false; setActionView( 'actions' ); renderRuntimeState( data.state ); renderPlayerContext( data.context ); renderStoryletActions( data.actions ); renderControls(); emitModeEvent(); /* * Place keyboard focus on the first available Action so the * transition works cleanly for keyboard players too. */ const firstAction = actionScreen ? actionScreen.querySelector( 'button:not([disabled]), a[href]' ) : null; if ( firstAction && typeof firstAction.focus === 'function' ) { firstAction.focus(); } } catch (error) { if (presentationContinueButton) { presentationContinueButton.disabled = false; } window.alert( error instanceof Error ? error.message : 'Could not continue.' ); } finally { presentationRequestInFlight = false; /* WAYFARER_PRESENTATION_CONTROL_RESET_V62 * * dismissPresentation() previously refreshed Action controls while * presentationRequestInFlight was still true. Newly-rendered Manual * Storylet buttons were therefore left disabled after a Location or * Storylet presentation was dismissed. Re-render once the request * flag has actually been cleared. */ renderControls(); if ( presentationContinueButton && presentationActive ) { presentationContinueButton.disabled = false; } } } if (onceButton) { onceButton.addEventListener( 'click', async function () { if ( running || presentationActive ) { return; } setMessage(''); await requestTick(); } ); } if (autoButton) { autoButton.addEventListener( 'click', startRuntime ); } if (authorPauseButton) { authorPauseButton.addEventListener( 'click', function () { pauseRuntime( 'Automatic advancement paused.' ); } ); } if (continueTravelButton) { continueTravelButton.addEventListener( 'click', startRuntime ); } if (playerPauseButton) { playerPauseButton.addEventListener( 'click', function () { /* * WAYFARER_SMOOTH_MANUAL_PAUSE_V46_2 * * v46.1 keeps Action context current in-place, so manual Pause * no longer needs a full page reload. */ pauseRuntime( 'Travel paused.' ); } ); } /* * WAYFARER_ACTION_UTILITY_VIEWS_V48 */ /* * WAYFARER_DYNAMIC_ACTION_SUBVIEWS_V51 * * Ensure V50 detail panels physically belong to the Action card. This is * harmless when the PHP view already placed them correctly, and repairs * any earlier V50 markup that landed just outside the host. */ if (actionScreen) { document .querySelectorAll( '.wayfarer-entity-detail-view' ) .forEach( function (panel) { if ( !actionScreen.contains( panel ) ) { actionScreen.appendChild( panel ); } } ); /* * Delegate navigation from the Action card itself. Any current or * future control using the generic data attributes automatically * participates in the same router. */ actionScreen.addEventListener( 'click', function (event) { const storyletAction = event.target.closest( '[data-wayfarer-storylet-action-id]' ); if ( storyletAction && actionScreen.contains( storyletAction ) ) { if ( running || presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( storyletAction.dataset .wayfarerStoryletActionId, 'action', 0 ); return; } const opener = event.target.closest( '[data-wayfarer-open-action-view]' ); if ( opener && actionScreen.contains( opener ) ) { if ( running || presentationActive ) { return; } event.preventDefault(); setActionView( opener.dataset .wayfarerOpenActionView || 'actions' ); return; } const closer = event.target.closest( '[data-wayfarer-close-action-view]' ); if ( !closer || !actionScreen.contains( closer ) ) { return; } event.preventDefault(); const returnView = closer.dataset .wayfarerReturnActionView || 'actions'; setActionView( returnView ); let focusTarget = null; if (returnView === 'actions') { focusTarget = actionList ? actionList.querySelector( 'button:not([disabled]), a[href]' ) : null; } else { const parentPanel = actionScreen.querySelector( '[data-wayfarer-action-view="' + returnView + '"]' ); focusTarget = parentPanel ? parentPanel.querySelector( '[data-wayfarer-open-action-view], button:not([disabled]), a[href]' ) : null; } if ( focusTarget && typeof focusTarget.focus === 'function' ) { focusTarget.focus(); } } ); } /* WAYFARER_STORYLET_BRANCH_SELECTION_V59 */ document.addEventListener( 'click', function (event) { const branch = event.target.closest( '[data-wayfarer-storylet-branch-id]' ); if (!branch) { return; } if ( !presentationActive || storyletRequestInFlight ) { return; } event.preventDefault(); requestStorylet( branch.dataset .wayfarerStoryletBranchId, 'branch', branch.dataset .wayfarerStoryletParentId ); } ); if (presentationContinueButton) { presentationContinueButton.addEventListener( 'click', dismissPresentation ); } document.addEventListener( 'keydown', function (event) { if ( !presentationActive || presentationRequestInFlight || storyletRequestInFlight || !presentationContinueButton ) { return; } const target = event.target; if ( target && target.matches && target.matches( 'input, textarea, select, button, a[href], [contenteditable="true"]' ) ) { return; } if ( event.key !== 'Enter' && event.key !== ' ' ) { return; } event.preventDefault(); dismissPresentation(); } ); window.addEventListener( 'pagehide', function () { running = false; if (timeoutId !== null) { window.clearTimeout( timeoutId ); timeoutId = null; } } ); window.wayfarerRuntimeController = { start: startRuntime, pause: pauseRuntime, dismissPresentation: dismissPresentation, enterStorylet: requestStorylet, isRunning: function () { return running; }, mode: function () { if (presentationActive) { return 'presentation'; } return running ? 'running' : 'paused'; } }; setActionView( 'actions' ); renderControls(); emitModeEvent(); })(); /* |-------------------------------------------------------------------------- | WAYFARER PLAY SHELL v20 |-------------------------------------------------------------------------- | | Reorganizes existing server-rendered nodes into the viewport shell. | Existing nodes are MOVED, not cloned/replaced, so the tick-control | event listeners installed above remain intact. | */ (() => { 'use strict'; if ( document.getElementById( 'wayfarer-play-shell' ) ) { return; } function exactHeading( level, text ) { const headings = document.querySelectorAll( level ); for (const heading of headings) { if ( heading.textContent .replace(/\s+/g, ' ') .trim() === text ) { return heading; } } return null; } function moveRange( start, stopExclusive, destination ) { if ( !start || !destination ) { return; } let node = start; while ( node && node !== stopExclusive ) { const next = node.nextSibling; destination.appendChild( node ); node = next; } } const body = document.body; /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * PHP decides whether Author Mode is permitted. JavaScript only shapes the * interface around that server-side decision. */ const canUseAuthorMode = body.dataset.wayfarerAuthorMode === '1'; body.classList.add( 'wayfarer-play-page' ); const shell = document.createElement( 'div' ); shell.id = 'wayfarer-play-shell'; shell.className = 'wayfarer-play-shell'; const header = document.createElement( 'header' ); header.className = 'wayfarer-play-shell-header'; const title = document.createElement( 'div' ); title.className = 'wayfarer-play-title'; const actions = document.createElement( 'div' ); actions.className = 'wayfarer-shell-actions'; const toggleAuthor = document.createElement( 'button' ); toggleAuthor.type = 'button'; toggleAuthor.className = 'wayfarer-shell-button'; if (canUseAuthorMode) { actions.appendChild( toggleAuthor ); } header.appendChild( title ); if (canUseAuthorMode) { header.appendChild( actions ); } const main = document.createElement( 'main' ); main.className = 'wayfarer-play-main'; const playerPane = document.createElement( 'section' ); playerPane.className = 'wayfarer-player-pane'; const authorPane = document.createElement( 'aside' ); authorPane.className = 'wayfarer-author-pane'; const authorHeader = document.createElement( 'div' ); authorHeader.className = 'wayfarer-author-header'; const authorToolbar = document.createElement( 'div' ); authorToolbar.className = 'wayfarer-author-toolbar'; const tabBar = document.createElement( 'div' ); tabBar.className = 'wayfarer-author-tabs'; tabBar.setAttribute( 'role', 'tablist' ); const authorContent = document.createElement( 'div' ); authorContent.className = 'wayfarer-author-content'; authorPane.appendChild( authorHeader ); authorPane.appendChild( authorToolbar ); authorPane.appendChild( tabBar ); authorPane.appendChild( authorContent ); main.appendChild( playerPane ); if (canUseAuthorMode) { main.appendChild( authorPane ); } else { /* * Keep the player workspace full-width when Author Mode is unavailable. * No Author pane was rendered by PHP and none is attached here. */ shell.classList.add( 'author-collapsed' ); } shell.appendChild( header ); shell.appendChild( main ); body.insertBefore( shell, body.firstChild ); // ------------------------------------------------------------ // Top title/player identity // ------------------------------------------------------------ const pageTitle = Array.from( body.querySelectorAll( ':scope > h1' ) )[0]; if (pageTitle) { const playerIdentity = pageTitle.nextElementSibling; title.appendChild( pageTitle ); if ( playerIdentity && playerIdentity.tagName === 'P' ) { title.appendChild( playerIdentity ); } } // ------------------------------------------------------------ // Player content // ------------------------------------------------------------ /* * WAYFARER_PLAYER_START_SCREEN_V36 * * Before runtime state exists, the start screen owns the full player * workspace and Author Mode is intentionally hidden. */ const startScreen = document.querySelector( '[data-wayfarer-start-screen]' ); if (startScreen) { shell.classList.add( 'is-starting' ); playerPane.appendChild( startScreen ); } const scene = document.getElementById( 'wayfarer-player-scene' ); const gameStateHeading = exactHeading( 'h2', 'Game State' ) || exactHeading( 'h2', 'Game Not Initialized' ); if (gameStateHeading) { const meta = document.createElement( 'div' ); meta.className = 'wayfarer-player-meta'; /* * Move from Game State through the node immediately before the * player scene. This includes initialization messaging/forms but * stops before the actual scene. */ moveRange( gameStateHeading, scene, meta ); playerPane.appendChild( meta ); } if (scene) { playerPane.appendChild( scene ); } /* * WAYFARER_ACTION_SCREEN_V39 * * Action Mode is player-facing and exists independently of Author Mode. */ const actionScreen = document.querySelector( '[data-wayfarer-action-screen]' ); if (actionScreen) { playerPane.appendChild( actionScreen ); } /* * WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 */ const presentationScreen = document.querySelector( '[data-wayfarer-presentation-screen]' ); if (presentationScreen) { playerPane.appendChild( presentationScreen ); } const travelControls = document.querySelector( '[data-wayfarer-travel-controls]' ); if (travelControls) { playerPane.appendChild( travelControls ); } // ------------------------------------------------------------ // Author header // ------------------------------------------------------------ const authorHeading = exactHeading( 'h2', 'Author Mode' ); if (authorHeading) { const builderLinkBlock = authorHeading.nextElementSibling; authorHeader.appendChild( authorHeading ); if ( builderLinkBlock && builderLinkBlock.tagName === 'P' ) { authorHeader.appendChild( builderLinkBlock ); } } // ------------------------------------------------------------ // Runtime controls // ------------------------------------------------------------ const tickPanel = document.getElementById( 'wayfarer-tick-test' ); if (tickPanel) { authorToolbar.appendChild( tickPanel ); } // ------------------------------------------------------------ // Author tabs // ------------------------------------------------------------ /* WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 */ const panelDefinitions = [ { key: 'state', label: 'State' }, { key: 'debug', label: 'Debug' } ]; function activateTab(key) { document .querySelectorAll( '.wayfarer-author-tab' ) .forEach( function (button) { const active = button.dataset .wayfarerAuthorTab === key; button.classList.toggle( 'is-active', active ); button.setAttribute( 'aria-selected', active ? 'true' : 'false' ); } ); authorContent .querySelectorAll( '.wayfarer-author-tab-panel' ) .forEach( function (panel) { panel.classList.toggle( 'is-active', panel.dataset .wayfarerAuthorPanel === key ); } ); try { window.localStorage.setItem( 'wayfarerAuthorTab', key ); } catch (error) { // Storage is optional. } } for (const definition of panelDefinitions) { const panel = document.querySelector( '[data-wayfarer-author-panel="' + definition.key + '"]' ); if (!panel) { continue; } authorContent.appendChild( panel ); const button = document.createElement( 'button' ); button.type = 'button'; button.className = 'wayfarer-author-tab'; button.dataset.wayfarerAuthorTab = definition.key; button.setAttribute( 'role', 'tab' ); button.textContent = definition.label; button.addEventListener( 'click', function () { activateTab( definition.key ); } ); tabBar.appendChild( button ); } let initialTab = 'state'; try { let savedTab = window.localStorage.getItem( 'wayfarerAuthorTab' ); if ( savedTab === 'region' ) { savedTab = 'state'; window.localStorage.setItem( 'wayfarerAuthorTab', 'state' ); } if ( savedTab && authorContent.querySelector( '[data-wayfarer-author-panel="' + savedTab + '"]' ) ) { initialTab = savedTab; } } catch (error) { // Storage is optional. } activateTab( initialTab ); // ------------------------------------------------------------ // Hide / Show Author Mode // WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 // ------------------------------------------------------------ if (canUseAuthorMode) { function renderAuthorToggle() { const collapsed = shell.classList.contains( 'author-collapsed' ); toggleAuthor.textContent = collapsed ? 'Show Author' : 'Hide Author'; toggleAuthor.setAttribute( 'aria-expanded', collapsed ? 'false' : 'true' ); } toggleAuthor.addEventListener( 'click', function () { shell.classList.toggle( 'author-collapsed' ); try { window.localStorage.setItem( 'wayfarerAuthorCollapsed', shell.classList.contains( 'author-collapsed' ) ? '1' : '0' ); } catch (error) { // Storage is optional. } renderAuthorToggle(); } ); try { if ( window.localStorage.getItem( 'wayfarerAuthorCollapsed' ) === '1' ) { shell.classList.add( 'author-collapsed' ); } } catch (error) { // Storage is optional. } renderAuthorToggle(); } else { /* * Player role is not merely a hidden Author interface. Keep the shell * permanently in player-only layout. */ shell.classList.add( 'author-collapsed' ); } // ------------------------------------------------------------ // Remove otherwise-empty top-level HRs left by the old document flow. // ------------------------------------------------------------ body .querySelectorAll( ':scope > hr' ) .forEach( function (rule) { rule.remove(); } ); })(); /* |-------------------------------------------------------------------------- | ENDING RESTART | WAYFARER_ENDING_RESTART_V63 |-------------------------------------------------------------------------- */ (() => { 'use strict'; const config = window.wayfarerTickRuntime; const restartButton = document.querySelector( '[data-wayfarer-restart-game]' ); if ( !config || !restartButton ) { return; } let requestInFlight = false; restartButton.addEventListener( 'click', async function () { if (requestInFlight) { return; } if ( !window.confirm( 'Start this game over? Your current playthrough progress will be erased.' ) ) { return; } requestInFlight = true; restartButton.disabled = true; const originalText = restartButton.textContent; restartButton.textContent = 'Starting over…'; try { const response = await fetch( config.restartEndpoint || 'api/restart.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ game_user_id: config.gameUserId, csrf_token: config.csrfToken }) } ); let data; try { data = await response.json(); } catch (error) { throw new Error( 'Restart endpoint did not return valid JSON.' ); } if ( !response.ok || !data.ok ) { throw new Error( data.error || 'Could not restart the game.' ); } document.body.classList.add( 'wayfarer-presentation-transitioning' ); window.location.href = 'index.php'; } catch (error) { requestInFlight = false; restartButton.disabled = false; restartButton.textContent = originalText; window.alert( error instanceof Error ? error.message : 'Could not restart the game.' ); } } ); })(); ================================================================================ FILE: update-backups/v64-20260926-011117/play/index.php ================================================================================ prepare(" SELECT g.id AS game_id, g.name AS game_name, g.slug AS game_slug, g.instructions AS game_instructions, g.player_slots AS player_slots, g.config AS game_config, gu.id AS game_user_id, gu.role AS game_user_role, gu.state, u.username, u.display_name FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id INNER JOIN wf_users u ON u.id = gu.user_id WHERE gu.game_id = ? AND gu.user_id = ? LIMIT 1 "); $stmt->execute([ $gameId, $userId ]); $gameUser = $stmt->fetch(); if (!$gameUser) { die('Game user not found.'); } /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * Author permission belongs to the user/game relationship, not to the user * globally. wf_game_users.role is therefore the canonical source. */ $canUseAuthorMode = strtolower( trim( (string)( $gameUser['game_user_role'] ?? '' ) ) ) === 'author'; // ================================================================ // RUNTIME STATE // ================================================================ $state = null; if (!empty($gameUser['state'])) { $decodedState = json_decode( $gameUser['state'], true ); if (is_array($decodedState)) { $state = $decodedState; } } /* * WAYFARER_ENTITY_RUNTIME_STATE_PAGE_RECOVERY_V49 * * Existing development playthroughs should gain Entity state immediately on * page load. This is additive and does not overwrite current runtime values. */ if ($state !== null) { $runtimeRecoveryChanged = wayfarerRuntimeEnsureEntityState( $pdo, (int)$gameId, $state ); /* WAYFARER_PLAYER_TRAITS_PAGE_RECOVERY_V52 */ if ( wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$gameId, $state ) ) { $runtimeRecoveryChanged = true; } if ($runtimeRecoveryChanged) { $encodedRecoveredState = wayfarerRuntimeEncodeState( $state ); $entityRecoveryStmt = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $entityRecoveryStmt->execute([ $encodedRecoveredState, (int)$gameUser['game_user_id'], ]); /* * Keep Author Debug and any later page component on the same snapshot. */ $gameUser['state'] = $encodedRecoveredState; } } // ================================================================ // INITIALIZE GAME // ================================================================ $initializationError = ''; if ( $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'initialize' && $state === null ) { try { wayfarerRuntimeInitializeGame( $pdo, (int)$gameUser['game_user_id'], [ 'player_names' => $_POST['player_names'] ?? [], 'choices' => $_POST['setup_choices'] ?? [], ] ); /* * Reload from persisted state so every play-page component sees the * exact same initialized snapshot. */ header('Location: index.php'); exit; } catch (Throwable $exception) { $initializationError = $exception->getMessage(); } } // ================================================================ // AUTHOR MODE: GAME TRAIT DEFINITIONS // ================================================================ $gameTraitDefinitions = []; $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } foreach ($gameConfig['traits'] ?? [] as $trait) { if (empty($trait['id'])) { continue; } $gameTraitDefinitions[$trait['id']] = $trait; } ?> <?= htmlspecialchars($gameUser['game_name']) ?>

Player:



================================================================================ FILE: update-backups/v65-20260926-011743/includes/gameplay/tick-effects-editor.php ================================================================================ Select target...'; if ($gameplayTargetCatalog) { $selectedKey = (string)( $tickEffect['target_scope'] ?? '' ) . ':' . (string)( $tickEffect['target_type'] ?? '' ) . ':' . (string)( $tickEffect['target_id'] ?? '' ); $grouped = []; foreach ( $gameplayTargetCatalog as $value => $target ) { if (!is_array($target)) { continue; } $group = (string)( $target['group'] ?? 'Targets' ); if (!isset($grouped[$group])) { $grouped[$group] = []; } $grouped[$group][$value] = $target; } foreach ($grouped as $group => $options) { echo ''; foreach ($options as $value => $target) { echo ''; } echo ''; } return; } if ($gameplayAllowNavigation) { echo ''; echo ''; echo ''; } if ($gameplayGameTraitTargets) { echo ''; foreach ( $gameplayGameTraitTargets as $targetTrait ) { echo ''; } echo ''; } if ( $gameplayScopeKey !== '' && $gameplayScopeTraitTargets ) { echo ''; foreach ( $gameplayScopeTraitTargets as $targetTrait ) { echo ''; } echo ''; } }; $renderBranchResultFields = function ( array $branch, string $name ): void { $operation = gameplayTickEffectBranchOperation( $branch ); $value = gameplayTickEffectBranchValue( $branch ); $valueMode = gameplayTickEffectBranchValueMode( $branch ); $minimum = gameplayTickEffectBranchMin( $branch ); $maximum = gameplayTickEffectBranchMax( $branch ); ?>
data-gameplay-scope="" data-navigation-type="" >

Tick Effects

Each Tick Effect controls one target. Its results are checked from top to bottom. The first matching result wins within that Tick Effect. Separate Tick Effects remain independent.

$tickEffect ): ?>
$branch ): ?>
THEN

================================================================================ FILE: update-backups/v66-20260926-012116/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * Current Region trait targets. */ $gameplayScopeTraitTargets = gameplayNumericTraitTargets( $traits, 'region' ); /* * Shared editor context. */ $gameplayScopeKey = 'region'; $gameplayScopeLabel = 'Region'; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v67-20260926-015932/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v67-20260926-015932/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * Current Region trait targets. */ $gameplayScopeTraitTargets = gameplayNumericTraitTargets( $traits, 'region' ); /* * Shared editor context. */ $gameplayScopeKey = 'region'; $gameplayScopeLabel = 'Region'; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); /* * WAYFARER_CURRENT_REGION_TARGET_ALIAS_V66 * * A Region Tick Effect has always been able to target "region" as the current * Region. The universal catalog also contains concrete region: targets, * but those are not semantically identical: they name a specific Region. * * Add current-Region aliases for this editor's own Traits. This preserves * existing authored effects and still leaves every concrete Region target * available in the universal catalog. */ foreach ( $gameplayScopeTraitTargets as $currentRegionTrait ) { if (!is_array($currentRegionTrait)) { continue; } $currentRegionTraitId = trim( (string)( $currentRegionTrait['id'] ?? '' ) ); if ($currentRegionTraitId === '') { continue; } $aliasKey = 'region:trait:' . $currentRegionTraitId; if ( isset( $gameplayTargetCatalog[ $aliasKey ] ) ) { continue; } $traitType = strtolower( trim( (string)( $currentRegionTrait[ 'type' ] ?? 'number' ) ) ); if ($traitType === '') { $traitType = 'number'; } $gameplayTargetCatalog[ $aliasKey ] = [ 'scope' => 'region', 'target_scope' => 'region', 'target_type' => 'trait', 'target_id' => $currentRegionTraitId, 'trait_type' => $traitType, 'data_type' => $traitType, 'helper' => ucfirst($traitType), 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => $traitType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ], 'supports_random' => $traitType === 'number', 'group' => 'Current Region', 'label' => 'Current Region → ' . (string)( $currentRegionTrait[ 'name' ] ?? $currentRegionTraitId ), ]; } $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v67-20260926-015932/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v67-20260926-015932/includes/gameplay/tick-effects-editor.php ================================================================================ Select target...'; if ($gameplayTargetCatalog) { $selectedScope = (string)( $tickEffect['target_scope'] ?? '' ); $selectedType = (string)( $tickEffect['target_type'] ?? '' ); $selectedId = (string)( $tickEffect['target_id'] ?? '' ); $selectedKey = $selectedScope . ':' . $selectedType . ':' . $selectedId; /* * WAYFARER_LEGACY_TICK_TARGET_SELECTION_V65 * * Pre-universal-target Tick Effects did not always store * target_type. Infer the canonical catalog key for display so the * existing Effect remains selected instead of silently posting the * blank first option on the next Region save. */ if ( $selectedScope !== '' && $selectedType === '' && $selectedId !== '' ) { foreach ( $gameplayTargetCatalog as $catalogKey => $catalogTarget ) { if (!is_array($catalogTarget)) { continue; } if ( (string)( $catalogTarget[ 'target_scope' ] ?? '' ) === $selectedScope && (string)( $catalogTarget[ 'target_id' ] ?? '' ) === $selectedId ) { $selectedKey = (string)$catalogKey; break; } } } $grouped = []; foreach ( $gameplayTargetCatalog as $value => $target ) { if (!is_array($target)) { continue; } $group = (string)( $target['group'] ?? 'Targets' ); if (!isset($grouped[$group])) { $grouped[$group] = []; } $grouped[$group][$value] = $target; } foreach ($grouped as $group => $options) { echo ''; foreach ($options as $value => $target) { echo ''; } echo ''; } return; } if ($gameplayAllowNavigation) { echo ''; echo ''; echo ''; } if ($gameplayGameTraitTargets) { echo ''; foreach ( $gameplayGameTraitTargets as $targetTrait ) { echo ''; } echo ''; } if ( $gameplayScopeKey !== '' && $gameplayScopeTraitTargets ) { echo ''; foreach ( $gameplayScopeTraitTargets as $targetTrait ) { echo ''; } echo ''; } }; $renderBranchResultFields = function ( array $branch, string $name ): void { $operation = gameplayTickEffectBranchOperation( $branch ); $value = gameplayTickEffectBranchValue( $branch ); $valueMode = gameplayTickEffectBranchValueMode( $branch ); $minimum = gameplayTickEffectBranchMin( $branch ); $maximum = gameplayTickEffectBranchMax( $branch ); ?>
data-gameplay-scope="" data-navigation-type="" >

Tick Effects

Each Tick Effect controls one target. Its results are checked from top to bottom. The first matching result wins within that Tick Effect. Separate Tick Effects remain independent.

$tickEffect ): ?>
$branch ): ?>
THEN

================================================================================ FILE: update-backups/v68-20260926-021435/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); /* WAYFARER_REGION_TARGET_SAVE_CATALOG_V67 */ $allowedTargets = array_replace( $allowedTargets, gameplayCurrentRegionTargetAliases( $regionTraits ) ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021435/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * Current Region trait targets. */ $gameplayScopeTraitTargets = gameplayNumericTraitTargets( $traits, 'region' ); /* * Shared editor context. */ $gameplayScopeKey = 'region'; $gameplayScopeLabel = 'Region'; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); /* * WAYFARER_CURRENT_REGION_TARGET_ALIAS_V66 * * A Region Tick Effect has always been able to target "region" as the current * Region. The universal catalog also contains concrete region: targets, * but those are not semantically identical: they name a specific Region. * * Add current-Region aliases for this editor's own Traits. This preserves * existing authored effects and still leaves every concrete Region target * available in the universal catalog. */ foreach ( $gameplayScopeTraitTargets as $currentRegionTrait ) { if (!is_array($currentRegionTrait)) { continue; } $currentRegionTraitId = trim( (string)( $currentRegionTrait['id'] ?? '' ) ); if ($currentRegionTraitId === '') { continue; } $aliasKey = 'region:trait:' . $currentRegionTraitId; if ( isset( $gameplayTargetCatalog[ $aliasKey ] ) ) { continue; } $traitType = strtolower( trim( (string)( $currentRegionTrait[ 'type' ] ?? 'number' ) ) ); if ($traitType === '') { $traitType = 'number'; } $gameplayTargetCatalog[ $aliasKey ] = [ 'scope' => 'region', 'target_scope' => 'region', 'target_type' => 'trait', 'target_id' => $currentRegionTraitId, 'trait_type' => $traitType, 'data_type' => $traitType, 'helper' => ucfirst($traitType), 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => $traitType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ], 'supports_random' => $traitType === 'number', 'group' => 'Current Region', 'label' => 'Current Region → ' . (string)( $currentRegionTrait[ 'name' ] ?? $currentRegionTraitId ), ]; } /* WAYFARER_REGION_TARGET_CATALOG_USE_V67 */ $gameplayTargetCatalog = array_replace( $gameplayTargetCatalog, gameplayCurrentRegionTargetAliases( $traits ) ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021435/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CURRENT_REGION_TARGET_CATALOG_V67 * * Region-authored gameplay needs a semantic "Current Region" scope in * addition to the universal catalog's concrete region: scopes. * * Use the same target metadata builder as the universal catalog so datatype, * operations, Random support, conditions, and future target metadata remain * consistent in both the editor and save validation. */ function gameplayCurrentRegionTargetAliases( array $regionTraits ): array { $targets = []; foreach ($regionTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region', 'Current Region', 'Current Region', $trait ); } return $targets; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021436/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021436/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021436/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021437/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021437/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021437/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021438/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021438/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021438/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021439/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021439/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021439/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021440/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021440/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021440/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021441/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021441/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021441/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021442/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021442/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021442/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021443/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021443/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021443/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021444/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021444/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021444/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021445/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021445/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021445/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021446/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021446/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021446/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021447/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021447/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021447/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021448/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021448/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021448/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021449/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021449/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021449/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021450/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021450/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021450/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021451/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021451/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021451/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021452/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021452/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021452/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021453/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021453/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021453/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021454/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021454/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021454/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021455/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021455/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021455/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021456/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021456/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021456/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021457/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021457/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021457/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021458/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021458/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021458/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021459/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021459/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021459/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021500/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021500/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021500/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021501/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021501/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021501/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021502/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021502/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021502/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021503/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021503/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021503/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021504/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021504/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021504/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021505/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021505/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021505/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021506/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021506/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021506/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021507/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021507/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021507/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021508/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021508/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021508/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021509/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021509/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021509/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021510/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021510/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021510/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021511/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021511/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021511/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021512/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021512/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021512/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021513/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021513/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021513/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021514/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021514/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021514/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021515/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021515/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021515/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021516/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021516/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021516/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021517/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021517/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021517/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021518/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021518/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021518/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021519/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021519/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021519/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021520/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021520/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021520/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021521/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021521/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021521/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021522/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021522/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021522/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021523/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021523/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021523/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021524/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021524/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021524/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021525/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021525/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021525/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021526/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021526/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021526/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021527/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021527/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021527/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021528/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021528/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021528/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021529/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021529/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021529/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021530/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021530/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021530/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021531/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021531/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021531/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021532/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021532/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021532/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021533/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021533/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021533/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021534/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021534/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021534/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021535/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021535/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021535/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v68-20260926-021536/includes/builder/save-record.php ================================================================================ prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); $gameConfig = []; if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decodedGameConfig = json_decode( (string)$gameConfigRaw, true ); if (is_array($decodedGameConfig)) { $gameConfig = $decodedGameConfig; } } /* WAYFARER_CANONICAL_REGION_TARGET_SAVE_V68 */ $currentRegionId = (int)( $record['id'] ?? 0 ); if ($currentRegionId > 0) { $postedEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $postedEffects, $currentRegionId ); } $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Location Conditions |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITIONS_SAVE_V45 */ function buildLocationConditionsFromPost( PDO $pdo, int $gameId, array $record, $postedConditions ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $rawGameConfig = $gameStmt->fetchColumn(); if ( $rawGameConfig !== false && $rawGameConfig !== null && $rawGameConfig !== '' ) { $decoded = json_decode( (string)$rawGameConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'conditions' => null, 'error' => 'A Location must belong to a Region before Conditions can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ($rawRegionConfig === false) { return [ 'conditions' => null, 'error' => 'The Location parent Region could not be loaded for Conditions.', ]; } $regionConfig = []; if ( $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $conditionResult = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $conditionResult['node'], 'error' => $conditionResult['error'], ]; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECTS_SAVE_V42 | | One-time Location Effects currently target numeric Game Traits and the | parent Region's numeric Traits. Navigation is intentionally excluded here | so an arrival Effect cannot move the player away from the Location that | just triggered its Presentation Mode. | */ function buildLocationEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $regionRaw = $regionStmt->fetchColumn(); if ($regionRaw === false) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Effects.', ]; } $regionConfig = []; if ( $regionRaw !== null && $regionRaw !== '' ) { $decoded = json_decode( (string)$regionRaw, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; /* * WAYFARER_GENERALIZED_LOCATION_EFFECT_SAVE_V43 * * Result conditions may inspect active Game/Region Traits, while the * Effect target itself remains restricted to numeric state. */ return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayRuntimeStateTargets( $pdo, $gameId ) ); } /* |-------------------------------------------------------------------------- | Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECTS_SAVE_V35 | | A Location effect may mutate Game Traits, the active Region's Traits, or | Navigation Position when that Location belongs to a linear Region. | Per-Location mutable Trait state does not exist yet, so Location Traits are | deliberately not exposed as effect targets. | */ function buildLocationTickEffectsFromPost( PDO $pdo, int $gameId, array $record, array $postedEffects ): array { $gameConfig = []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId ]); $gameConfigRaw = $gameStmt->fetchColumn(); if ( $gameConfigRaw !== false && $gameConfigRaw !== null && $gameConfigRaw !== '' ) { $decoded = json_decode( (string)$gameConfigRaw, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $regionId = (int)( $record['region_id'] ?? 0 ); if ($regionId <= 0) { return [ 'effects' => [], 'error' => 'A Location must belong to a Region before Tick Effects can be saved.', ]; } $regionStmt = $pdo->prepare( " SELECT navigation_type, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $region = $regionStmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { return [ 'effects' => [], 'error' => 'The Location parent Region could not be loaded for Tick Effects.', ]; } $regionConfig = []; if ( !empty( $region['config'] ) ) { $decoded = json_decode( (string)$region['config'], true ); if (is_array($decoded)) { $regionConfig = $decoded; } } $regionTraits = isset( $regionConfig['traits'] ) && is_array( $regionConfig['traits'] ) ? $regionConfig['traits'] : []; $allowLinearNavigation = strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) === 'linear'; $allowedTargets = gameplayRuntimeStateTargets( $pdo, $gameId ); $allowedConditionTargets = $allowedTargets; /* WAYFARER_BUILDER_UNIVERSAL_TARGETS_V56 */ return gameplayBuildTickEffectsFromPost( $allowedTargets, $postedEffects, $allowedConditionTargets ); } /* |-------------------------------------------------------------------------- | Storylet Conditions + Effects | WAYFARER_STORYLET_BUILDER_V56 |-------------------------------------------------------------------------- */ function buildStoryletGameConfig( PDO $pdo, int $gameId ): array { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $raw = $stmt->fetchColumn(); if ( $raw === false || $raw === null || $raw === '' ) { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function buildStoryletConditionsFromPost( PDO $pdo, int $gameId, $postedConditions ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); $result = gameplayBuildConditionNodeFromPost( $postedConditions, gameplayRuntimeStateTargets( $pdo, $gameId ) ); return [ 'conditions' => $result['node'], 'error' => $result['error'], ]; } function buildStoryletEffectsFromPost( PDO $pdo, int $gameId, array $postedEffects ): array { $gameConfig = buildStoryletGameConfig( $pdo, $gameId ); return gameplayBuildEffectsFromPost( gameplayRuntimeStateTargets( $pdo, $gameId ), $postedEffects, gameplayConditionGameWideTargets( $pdo, $gameId, $gameConfig['traits'] ?? [] ) ); } function validateStoryletParentChain( PDO $pdo, int $gameId, int $storyletId, int $parentStoryletId ): string { if ($parentStoryletId <= 0) { return ''; } if ( $storyletId > 0 && $parentStoryletId === $storyletId ) { return 'A Storylet cannot be its own Parent Storylet.'; } $seen = []; $currentId = $parentStoryletId; while ($currentId > 0) { if ( $storyletId > 0 && $currentId === $storyletId ) { return 'Parent Storylet would create a circular Choice chain.'; } if (isset($seen[$currentId])) { return 'The selected Parent Storylet is already part of a circular Choice chain.'; } $seen[$currentId] = true; $stmt = $pdo->prepare( " SELECT parent_storylet_id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $currentId, $gameId, ]); $parent = $stmt->fetchColumn(); if ($parent === false) { return 'Invalid Parent Storylet.'; } $currentId = $parent === null || $parent === '' ? 0 : (int)$parent; } return ''; } function buildSystemTraitForStorage( string $groupName, string $traitName, array $traitDefinition, $value, $authorMax = null ): array { $traitType = $traitDefinition['type'] ?? 'text'; if ($traitType === 'number') { if (filter_var($value, FILTER_VALIDATE_INT) !== false) { $value = (int)$value; } else { $value = (float)$value; } } elseif ($traitType === 'binary') { $value = (int)$value; } elseif ($traitType === 'boolean') { $value = in_array( (string)$value, ['1', 'true', 'yes', 'on'], true ); } return [ 'id' => 'sys_' . $groupName . '_' . $traitName, 'name' => $traitName, 'type' => $traitType, 'min' => $traitDefinition['min'] ?? null, 'max' => $authorMax !== null ? $authorMax : ($traitDefinition['max'] ?? null), 'initial' => $value, 'active' => true, 'notes' => $traitDefinition['help'] ?? '', 'system' => true, 'system_group' => $groupName, 'system_key' => $traitName, ]; } /* |-------------------------------------------------------------------------- | Region Player HUD | WAYFARER_REGION_HUD_SAVE_V64 |-------------------------------------------------------------------------- */ function buildRegionHudFromPost( PDO $pdo, int $gameId, array $regionTraits, array $postedHud ): array { $enabled = !empty( $postedHud['enabled'] ); $postedComponents = isset($postedHud['components']) && is_array($postedHud['components']) ? array_values( $postedHud['components'] ) : []; $gameStmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $gameStmt->execute([ $gameId, ]); $gameRaw = $gameStmt->fetchColumn(); $gameConfig = $gameRaw ? json_decode( (string)$gameRaw, true ) : []; if (!is_array($gameConfig)) { $gameConfig = []; } $catalog = [ 'game' => [], 'region' => [], ]; $indexTrait = static function ( array &$bucket, array $trait ): void { $id = trim( (string)( $trait['id'] ?? '' ) ); if ($id === '') { return; } $bucket[$id] = $trait; }; foreach ( ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ) as $trait ) { if (is_array($trait)) { $indexTrait( $catalog['game'], $trait ); } } foreach ($regionTraits as $trait) { if (is_array($trait)) { $indexTrait( $catalog['region'], $trait ); } } $components = []; foreach ( $postedComponents as $rowIndex => $postedComponent ) { if (!is_array($postedComponent)) { continue; } $type = strtolower( trim( (string)( $postedComponent['type'] ?? 'progress_bar' ) ) ); if ($type !== 'progress_bar') { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' has an unsupported component type.', ]; } $source = trim( (string)( $postedComponent['source'] ?? '' ) ); $parts = explode( '|', $source, 2 ); $scope = strtolower( trim( (string)( $parts[0] ?? '' ) ) ); $sourceId = trim( (string)( $parts[1] ?? '' ) ); if ( !in_array( $scope, ['game', 'region'], true ) || $sourceId === '' ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must select a Trait source.', ]; } $definition = $catalog[$scope][ $sourceId ] ?? null; if ( !is_array($definition) || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' || !is_numeric( $definition['min'] ?? null ) || !is_numeric( $definition['max'] ?? null ) || (float)$definition['max'] <= (float)$definition['min'] ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' must use a bounded Number Trait with both Minimum and Maximum values.', ]; } $label = trim( (string)( $postedComponent['label'] ?? '' ) ); if ( function_exists( 'mb_strlen' ) ? mb_strlen( $label ) > 120 : strlen( $label ) > 120 ) { return [ 'hud' => [], 'error' => 'HUD component ' . ($rowIndex + 1) . ' label must be 120 characters or fewer.', ]; } $componentId = trim( (string)( $postedComponent['id'] ?? '' ) ); if ($componentId === '') { $componentId = 'hud_' . bin2hex( random_bytes(6) ); } $components[] = [ 'id' => $componentId, 'type' => 'progress_bar', 'source_scope' => $scope, 'source_id' => $sourceId, 'label' => $label, 'show_value' => !empty( $postedComponent[ 'show_value' ] ), ]; } return [ 'hud' => [ 'enabled' => $enabled, 'components' => $components, ], 'error' => '', ]; } /** * Validates and writes a builder record. */ function saveBuilderRecord( PDO $pdo, array $definition, int $gameId, array $post, array $record, array $configValues, array $traits ): array { $table = $definition['table']; $relationships = $definition['relationships'] ?? []; $fields = $definition['fields'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; $systemTraitsDefinition = $definition['system_traits'] ?? []; $systemSelectorField = $systemTraitsDefinition['selector_field'] ?? null; $systemGroups = $systemTraitsDefinition['groups'] ?? []; $error = ''; $postedId = !empty($post['id']) ? (int)$post['id'] : 0; foreach ($relationships as $fieldName => $relationship) { $value = $post[$fieldName] ?? ''; $record[$fieldName] = $value === '' ? '' : (int)$value; } foreach ($fields as $fieldName => $field) { $value = $post[$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $record[$fieldName] = $value; } foreach ($configFields as $fieldName => $field) { $value = $post['config'][$fieldName] ?? ''; if (is_string($value)) { $value = trim($value); } $configValues[$fieldName] = $value; } foreach ($relationships as $fieldName => $relationship) { if ( !empty($relationship['required']) && empty($record[$fieldName]) ) { $error = $relationship['label'] . ' is required.'; break; } } $customTraits = []; if (!empty($definition['traits'])) { $traitResult = buildTraitsFromPost($post['traits'] ?? []); $customTraits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } } $systemTraits = []; if ( $error === '' && $systemSelectorField && !empty($systemGroups) ) { $activeSystemGroup = (string)($record[$systemSelectorField] ?? ''); $activeGroupDefinition = $systemGroups[$activeSystemGroup] ?? null; if ($activeGroupDefinition) { foreach ( ($activeGroupDefinition['traits'] ?? []) as $traitName => $traitDefinition ) { $value = $post['system_traits'][$activeSystemGroup][$traitName] ?? ($traitDefinition['default'] ?? ''); if (is_string($value)) { $value = trim($value); } if ( !empty($traitDefinition['required']) && $value === '' ) { $error = ($traitDefinition['label'] ?? $traitName) . ' is required.'; break; } $traitType = $traitDefinition['type'] ?? 'text'; if ( $traitType === 'number' && $value !== '' && !is_numeric($value) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be a number.'; break; } if ( $traitType === 'number' && $value !== '' && isset($traitDefinition['min']) && (float)$value < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ( $traitType === 'binary' && !in_array( (string)$value, ['0', '1'], true ) ) { $error = ($traitDefinition['label'] ?? $traitName) . ' must be No or Yes.'; break; } if ($traitType === 'select') { $options = $traitDefinition['options'] ?? []; if (!array_key_exists((string)$value, $options)) { $error = 'Invalid ' . ($traitDefinition['label'] ?? $traitName) . '.'; break; } } $authorMax = null; if ( $traitType === 'number' && !empty($traitDefinition['allow_author_max']) ) { $postedMax = $post['system_trait_meta'] [$activeSystemGroup] [$traitName] ['max'] ?? ''; if (is_string($postedMax)) { $postedMax = trim($postedMax); } if ($postedMax !== '') { if (!is_numeric($postedMax)) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be a number.'; break; } if ( isset($traitDefinition['min']) && (float)$postedMax < (float)$traitDefinition['min'] ) { $error = ($traitDefinition['max_label'] ?? 'Maximum') . ' must be at least ' . $traitDefinition['min'] . '.'; break; } if ((float)$value > (float)$postedMax) { $error = 'Starting Quantity cannot be greater than ' . 'Maximum Quantity.'; break; } if ( filter_var( $postedMax, FILTER_VALIDATE_INT ) !== false ) { $authorMax = (int)$postedMax; } else { $authorMax = (float)$postedMax; } } } $systemTraits[] = buildSystemTraitForStorage( $activeSystemGroup, $traitName, $traitDefinition, $value, $authorMax ); } } } if ($error === '' && !empty($systemTraits)) { $reservedNames = []; foreach ($systemTraits as $systemTrait) { $reservedNames[] = strtolower((string)$systemTrait['name']); } foreach ($customTraits as $customTrait) { $customName = strtolower(trim((string)($customTrait['name'] ?? ''))); if ( $customName !== '' && in_array($customName, $reservedNames, true) ) { $error = '"' . ($customTrait['name'] ?? '') . '" is a Wayfarer system trait for this entity type. ' . 'Choose a different custom trait name.'; break; } } } $traits = array_merge($systemTraits, $customTraits); /* WAYFARER_REGION_HUD_SAVE_V64 */ $regionHud = [ 'enabled' => false, 'components' => [], ]; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $regionHudResult = buildRegionHudFromPost( $pdo, $gameId, $traits, isset($post['hud']) && is_array($post['hud']) ? $post['hud'] : [] ); $regionHud = $regionHudResult['hud'] ?? $regionHud; if ( ($regionHudResult['error'] ?? '') !== '' ) { $error = (string)$regionHudResult['error']; } } /* WAYFARER_REGION_TICK_EFFECTS_PARSE_V18 */ $regionTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_regions' ) { $tickEffectResult = buildRegionTickEffectsFromPost( $pdo, $gameId, $record, $traits, $post['tick_effects'] ?? [] ); $regionTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* * WAYFARER_LOCATION_CONDITIONS_PARSE_V45 */ $locationConditions = null; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $conditionResult = buildLocationConditionsFromPost( $pdo, $gameId, $record, $post['conditions'] ?? null ); $locationConditions = $conditionResult[ 'conditions' ]; if ( $conditionResult['error'] !== '' ) { $error = $conditionResult[ 'error' ]; } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_PARSE_V42 */ $locationEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $effectResult = buildLocationEffectsFromPost( $pdo, $gameId, $record, $post['effects'] ?? [] ); $locationEffects = $effectResult[ 'effects' ]; if ( $effectResult['error'] !== '' ) { $error = $effectResult[ 'error' ]; } } /* WAYFARER_LOCATION_TICK_EFFECTS_PARSE_V35 */ $locationTickEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_locations' ) { $tickEffectResult = buildLocationTickEffectsFromPost( $pdo, $gameId, $record, $post['tick_effects'] ?? [] ); $locationTickEffects = $tickEffectResult[ 'effects' ]; if ( $tickEffectResult['error'] !== '' ) { $error = $tickEffectResult[ 'error' ]; } } /* WAYFARER_STORYLET_PARSE_V56 */ $storyletConditions = null; $storyletEffects = []; if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $conditionResult = buildStoryletConditionsFromPost( $pdo, $gameId, $post['conditions'] ?? null ); $storyletConditions = $conditionResult['conditions']; if ($conditionResult['error'] !== '') { $error = $conditionResult['error']; } } if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $effectResult = buildStoryletEffectsFromPost( $pdo, $gameId, isset($post['effects']) && is_array($post['effects']) ? array_values($post['effects']) : [] ); $storyletEffects = $effectResult['effects']; if ($effectResult['error'] !== '') { $error = $effectResult['error']; } } if ($error === '') { foreach ($fields as $fieldName => $field) { if ( !empty($field['required']) && $record[$fieldName] === '' ) { $error = $field['label'] . ' is required.'; break; } } } if ($error === '') { foreach ($relationships as $fieldName => $relationship) { if (empty($record[$fieldName])) { continue; } $relationshipTable = $relationship['table']; $valueField = $relationship['value_field']; $gameField = $relationship['game_field']; $sql = " SELECT COUNT(*) FROM `$relationshipTable` WHERE `$valueField` = ? AND `$gameField` = ? "; $stmt = $pdo->prepare($sql); $stmt->execute([ $record[$fieldName], $gameId, ]); if (!$stmt->fetchColumn()) { $error = 'Invalid ' . $relationship['label'] . '.'; break; } } } /* WAYFARER_STORYLET_PARENT_VALIDATION_V56 */ if ( $error === '' && ($definition['table'] ?? '') === 'wf_storylets' ) { $error = validateStoryletParentChain( $pdo, $gameId, $postedId, (int)($record['parent_storylet_id'] ?? 0) ); } if ($error === '') { foreach ($configFields as $fieldName => $field) { $applies = configFieldApplies($pdo, $field, $record); if (!$applies) { continue; } $value = $configValues[$fieldName] ?? ''; if ( !empty($field['required']) && $value === '' ) { $error = $field['label'] . ' is required.'; break; } if ( $value !== '' && ($field['type'] ?? '') === 'number' && !is_numeric($value) ) { $error = $field['label'] . ' must be a number.'; break; } if ( $value !== '' && isset($field['min']) && (float)$value < (float)$field['min'] ) { $error = $field['label'] . ' must be at least ' . $field['min'] . '.'; break; } } } /* WAYFARER_CONFIG_MAX_VALIDATION_V56 */ if ($error === '') { foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ( $value !== '' && ($field['type'] ?? '') === 'number' && is_numeric($value) && isset($field['max']) && (float)$value > (float)$field['max'] ) { $error = $field['label'] . ' must be no more than ' . $field['max'] . '.'; break; } } } if ($error !== '') { return [ 'saved' => false, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => $error, ]; } $writeFields = []; foreach ($relationships as $fieldName => $relationship) { $writeFields[] = $fieldName; } foreach ($fields as $fieldName => $field) { $writeFields[] = $fieldName; } $configToSave = []; if (!empty($record['config'])) { $existingConfig = json_decode($record['config'], true); if (is_array($existingConfig)) { $configToSave = $existingConfig; } } unset($configToSave['system_traits']); foreach ($configFields as $fieldName => $field) { if (!configFieldApplies($pdo, $field, $record)) { continue; } $value = $configValues[$fieldName] ?? ''; if ($value === '') { unset($configToSave[$fieldName]); continue; } if (($field['type'] ?? '') === 'number') { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { $value = (int)$value; } else { $value = (float)$value; } } $configToSave[$fieldName] = $value; } if ( !empty($definition['traits']) || !empty($definition['system_traits']) ) { /* * WAYFARER_ENTITY_SYSTEM_CONFIG_V55 * * Entity system schema/help text is universal Wayfarer code and must * not be duplicated into every wf_entities.config JSON document. */ if ( ($definition['table'] ?? '') === 'wf_entities' && !empty($definition['system_traits']) ) { $compactSystem = []; foreach ($systemTraits as $systemTrait) { if (!is_array($systemTrait)) { continue; } $systemKey = (string)($systemTrait['system_key'] ?? ''); if ($systemKey === 'quantity') { $compactSystem['quantity'] = [ 'initial' => $systemTrait['initial'] ?? 0, 'max' => $systemTrait['max'] ?? null, ]; } elseif ($systemKey === 'inventory_visibility') { $compactSystem['inventory_visibility'] = (string)( $systemTrait['initial'] ?? 'while_possessed' ); } elseif ($systemKey === 'party_member') { $compactSystem['party_member'] = (int)($systemTrait['initial'] ?? 0); } } $configToSave['traits'] = array_values($customTraits); if ($compactSystem) { $configToSave['system'] = $compactSystem; } else { unset($configToSave['system']); } unset($configToSave['system_traits']); } else { $configToSave['traits'] = $traits; } /* WAYFARER_REGION_HUD_SAVE_V64 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ( !empty($regionHud['enabled']) || !empty($regionHud['components']) ) { $configToSave['hud'] = $regionHud; } else { unset( $configToSave['hud'] ); } } /* WAYFARER_REGION_TICK_EFFECTS_CONFIG_V18 */ if ( ($definition['table'] ?? '') === 'wf_regions' ) { if ($regionTickEffects) { $configToSave[ 'tick_effects' ] = $regionTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } /* * WAYFARER_LOCATION_CONDITIONS_CONFIG_V45 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationConditions !== null) { $configToSave[ 'conditions' ] = $locationConditions; } else { unset( $configToSave[ 'conditions' ] ); } } /* * WAYFARER_LOCATION_ONE_TIME_EFFECTS_CONFIG_V42 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationEffects) { $configToSave[ 'effects' ] = array_values( $locationEffects ); } else { unset( $configToSave[ 'effects' ] ); } } /* WAYFARER_LOCATION_TICK_EFFECTS_CONFIG_V35 */ if ( ($definition['table'] ?? '') === 'wf_locations' ) { if ($locationTickEffects) { $configToSave[ 'tick_effects' ] = $locationTickEffects; } else { unset( $configToSave[ 'tick_effects' ] ); } } } /* WAYFARER_STORYLET_CONFIG_SAVE_V56 */ if ( ($definition['table'] ?? '') === 'wf_storylets' ) { if ($storyletConditions !== null) { $configToSave['conditions'] = $storyletConditions; } else { unset($configToSave['conditions']); } if ($storyletEffects) { $configToSave['effects'] = array_values($storyletEffects); } else { unset($configToSave['effects']); } } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $record['config'] = empty($configToSave) ? null : json_encode( $configToSave, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $writeFields[] = 'config'; } if ($postedId) { $scopeQuery = buildScopeQuery($definition); $checkSql = " SELECT `$table`.`id` FROM {$scopeQuery['from']} WHERE `$table`.`id` = ? AND {$scopeQuery['where']} LIMIT 1 "; $stmt = $pdo->prepare($checkSql); $stmt->execute([$postedId, $gameId]); if (!$stmt->fetch()) { die('Record not found.'); } $assignments = []; $values = []; foreach ($writeFields as $fieldName) { $assignments[] = "`$fieldName` = ?"; $value = $record[$fieldName] ?? null; /* WAYFARER_OPTIONAL_RELATIONSHIP_NULL_V56 */ if ( isset($relationships[$fieldName]) && $value === '' ) { $value = null; } if ( isset($fields[$fieldName]) && ($fields[$fieldName]['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } $values[] = $postedId; $sql = " UPDATE `$table` SET " . implode(', ', $assignments) . " WHERE id = ? "; $stmt = $pdo->prepare($sql); $stmt->execute($values); } else { $columns = []; $placeholders = []; $values = []; if ($scope['type'] === 'game') { $columns[] = "`{$scope['field']}`"; $placeholders[] = '?'; $values[] = $gameId; } foreach ($relationships as $fieldName => $relationship) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; /* WAYFARER_OPTIONAL_RELATIONSHIP_INSERT_V56 */ $relationshipValue = $record[$fieldName] === '' ? null : $record[$fieldName]; $values[] = $relationshipValue; } foreach ($fields as $fieldName => $field) { $columns[] = "`$fieldName`"; $placeholders[] = '?'; $value = $record[$fieldName]; if ( ($field['type'] ?? '') === 'number' && $value === '' ) { $value = null; } $values[] = $value; } if ( !empty($configFields) || !empty($definition['traits']) || !empty($definition['system_traits']) ) { $columns[] = '`config`'; $placeholders[] = '?'; $values[] = $record['config']; } $sql = " INSERT INTO `$table` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ") "; $stmt = $pdo->prepare($sql); $stmt->execute($values); $postedId = (int)$pdo->lastInsertId(); } return [ 'saved' => true, 'id' => $postedId, 'record' => $record, 'configValues' => $configValues, 'traits' => $traits, 'error' => '', ]; } ================================================================================ FILE: update-backups/v68-20260926-021536/includes/builder/tick-effects.php ================================================================================ 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionGameplayRecordId, $gameId, ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig !== false && $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } } elseif (!empty($record['config'])) { $decoded = json_decode( (string)$record['config'], true ); if (is_array($decoded)) { $regionGameplayConfig = $decoded; } } /* * Failed POSTs retain the author's submitted rows. */ if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ) { $gameplayTickEffects = array_values( $_POST['tick_effects'] ); } else { $gameplayTickEffects = isset( $regionGameplayConfig['tick_effects'] ) && is_array( $regionGameplayConfig['tick_effects'] ) ? array_values( $regionGameplayConfig['tick_effects'] ) : []; } /* WAYFARER_CANONICALIZE_REGION_EFFECTS_FOR_EDITOR_V68 */ if ($regionGameplayRecordId > 0) { $gameplayTickEffects = gameplayCanonicalizeLegacyCurrentRegionTickEffects( $gameplayTickEffects, $regionGameplayRecordId ); } /* * Game trait targets. */ $gameConfig = []; if (!empty($game['config'])) { $decoded = json_decode( (string)$game['config'], true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $gameplayGameTraitTargets = gameplayNumericTraitTargets( $gameConfig['traits'] ?? [], 'game' ); /* * WAYFARER_NO_CURRENT_REGION_ALIAS_V68 * * Region Tick Effects use the universal target catalog only. A Region Trait * is addressed by its actual Region id; there is no separate Current Region * authoring scope. */ $gameplayScopeTraitTargets = []; $gameplayScopeKey = ''; $gameplayScopeLabel = ''; $gameplayAllowNavigation = true; $gameplayNavigationType = (string)( $record['navigation_type'] ?? '' ); /* WAYFARER_UNIVERSAL_REGION_TARGETS_V56 */ /* WAYFARER_CANONICAL_REGION_TARGET_EDITOR_V68 */ $gameplayTargetCatalog = gameplayRuntimeStateTargets( $pdo, $gameId ); $gameplayConditionTargets = $gameplayTargetCatalog; $gameplayDescription = "These effects are applied while this Region is the player's current Region."; require __DIR__ . '/../gameplay/tick-effects-editor.php'; ================================================================================ FILE: update-backups/v68-20260926-021536/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: update-backups/v69-20260926-023733/wf_regions-1-config.json ================================================================================ { "traits": [], "tick_effects": [ { "target_scope": "navigation", "target_type": "position", "target_id": "position", "operation": "add", "value": 10, "branches": [ { "conditions": null, "operation": "add", "value": 10 } ] }, { "target_scope": "game", "target_type": "trait", "target_id": "tr_ad270ad492c4323a", "operation": "add", "value": 10, "branches": [ { "conditions": null, "operation": "add", "value": 10 } ] }, { "target_scope": "game", "target_type": "trait", "target_id": "tr_f00b336c519a0f4d", "operation": "add", "value": 2, "branches": [ { "conditions": { "type": "group", "mode": "all", "children": [ { "type": "condition", "scope": "game", "target_type": "trait", "target_id": "tr_ad270ad492c4323a", "operator": "greater_than_or_equal", "value": 50 } ] }, "operation": "subtract", "value": 5 }, { "conditions": null, "operation": "add", "value": 2 } ] } ] } ================================================================================ FILE: update-backups/v70-20260926-031619/play/assets/play.css ================================================================================ /* |-------------------------------------------------------------------------- | Wayfarer Play Shell v20 |-------------------------------------------------------------------------- */ html, body { height: 100%; } body.wayfarer-play-page { margin: 0; overflow: hidden; background: #eef1f5; color: #20242a; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.45; } .wayfarer-play-page * { box-sizing: border-box; } .wayfarer-play-page hr { display: none; } .wayfarer-play-shell { height: 100vh; display: flex; flex-direction: column; overflow: hidden; } .wayfarer-play-shell-header { flex: 0 0 auto; min-height: 68px; padding: 12px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; background: #ffffff; border-bottom: 1px solid #d8dbe2; } .wayfarer-play-title { min-width: 0; } .wayfarer-play-title h1 { margin: 0; font-size: 1.35rem; line-height: 1.2; } .wayfarer-play-title p { margin: 4px 0 0; color: #667085; font-size: 0.9rem; } .wayfarer-shell-actions { display: flex; align-items: center; gap: 8px; } .wayfarer-shell-button { border: 1px solid #b8bdc7; border-radius: 6px; padding: 7px 10px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 600; } .wayfarer-shell-button:hover { background: #f6f7f9; } .wayfarer-play-main { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 7fr) minmax(330px, 3fr); gap: 12px; padding: 12px; overflow: hidden; } .wayfarer-player-pane, .wayfarer-author-pane { min-width: 0; min-height: 0; background: #ffffff; border: 1px solid #d8dbe2; border-radius: 10px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); } .wayfarer-player-pane { overflow: auto; padding: 22px; } .wayfarer-player-pane > h2:first-child { margin-top: 0; } #wayfarer-player-scene { margin-top: 10px; } #wayfarer-player-scene h2 { margin-top: 0; } .wayfarer-author-pane { display: flex; flex-direction: column; overflow: hidden; } .wayfarer-author-header { flex: 0 0 auto; padding: 14px 16px 10px; border-bottom: 1px solid #e3e6eb; } .wayfarer-author-header h2 { margin: 0; font-size: 1.05rem; } .wayfarer-author-header p { margin: 6px 0 0; font-size: 0.85rem; } .wayfarer-author-header a { color: #2f5ea8; } .wayfarer-author-toolbar { flex: 0 0 auto; padding: 10px 14px; background: #f6f7f9; border-bottom: 1px solid #e3e6eb; } .wayfarer-author-toolbar #wayfarer-tick-test { margin: 0; } .wayfarer-author-toolbar #wayfarer-tick-test > h2 { margin: 0 0 6px; font-size: 0.9rem; } .wayfarer-author-toolbar #wayfarer-tick-test > p { margin: 5px 0; font-size: 0.82rem; } .wayfarer-author-toolbar button { margin-right: 4px; margin-bottom: 2px; padding: 5px 8px; } #wayfarer-tick-message { min-height: 1.2em; } .wayfarer-author-tabs { flex: 0 0 auto; display: flex; gap: 2px; padding: 8px 10px 0; background: #ffffff; border-bottom: 1px solid #d8dbe2; } .wayfarer-author-tab { appearance: none; border: 0; border-bottom: 2px solid transparent; padding: 8px 10px 7px; background: transparent; color: #667085; cursor: pointer; font: inherit; font-size: 0.86rem; font-weight: 600; } .wayfarer-author-tab:hover { color: #20242a; } .wayfarer-author-tab.is-active { color: #2f5ea8; border-bottom-color: #2f5ea8; } .wayfarer-author-content { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 14px 16px 18px; } .wayfarer-author-tab-panel { display: none; } .wayfarer-author-tab-panel.is-active { display: block; } .wayfarer-author-tab-panel > :first-child { margin-top: 0; } .wayfarer-author-tab-panel h3 { margin-top: 0; font-size: 1rem; } .wayfarer-author-tab-panel h4 { margin-bottom: 8px; } .wayfarer-author-tab-panel table { width: 100%; border-collapse: collapse; font-size: 0.84rem; } .wayfarer-author-tab-panel th, .wayfarer-author-tab-panel td { padding: 6px 7px; text-align: left; vertical-align: top; } .wayfarer-author-tab-panel pre { max-width: 100%; overflow: auto; font-size: 0.78rem; } #wayfarer-author-debug-panel details { margin-top: 0 !important; } #wayfarer-author-debug-panel summary { display: none; } #wayfarer-author-debug-panel details:not([open]) > *:not(summary) { display: block; } #wayfarer-author-debug-panel details > pre { display: block; max-height: none !important; margin-top: 0 !important; } .wayfarer-player-meta { margin-bottom: 14px; padding: 10px 12px; border-radius: 7px; background: #f6f7f9; color: #667085; font-size: 0.86rem; } .wayfarer-player-meta h2 { margin: 0 0 4px; color: #20242a; font-size: 0.95rem; } .wayfarer-player-meta p { margin: 0; } .wayfarer-play-shell.author-collapsed .wayfarer-play-main { grid-template-columns: minmax(0, 1fr); } .wayfarer-play-shell.author-collapsed .wayfarer-author-pane { display: none; } @media (max-width: 900px) { body.wayfarer-play-page { overflow: auto; } .wayfarer-play-shell { height: auto; min-height: 100vh; overflow: visible; } .wayfarer-play-main { display: block; overflow: visible; padding: 8px; } .wayfarer-player-pane, .wayfarer-author-pane { overflow: visible; margin-bottom: 10px; } .wayfarer-author-pane { min-height: 480px; } .wayfarer-author-content { overflow: visible; } .wayfarer-play-shell.author-collapsed .wayfarer-author-pane { display: none; } } /* ------------------------------------------------------------------ GAME START SCREEN WAYFARER_PLAYER_START_SCREEN_V36 ------------------------------------------------------------------ */ /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * The start screen no longer implies "no Author Mode." Authors may inspect * the Author panel before initialization. The existing author-collapsed class * controls the one-column layout for normal players and collapsed authors. */ .wayfarer-start-screen { width: min(860px, 100%); margin: 0 auto; } .wayfarer-start-screen-heading { margin-bottom: 24px; } .wayfarer-start-screen-heading h2 { margin: 0 0 12px; font-size: 1.65rem; } .wayfarer-start-introduction { max-width: 72ch; color: #4b5563; line-height: 1.65; } .wayfarer-start-error { margin-bottom: 18px; padding: 10px 12px; border: 1px solid #e4a7ad; border-radius: 7px; background: #fff4f5; color: #8b1e2d; } .wayfarer-start-form { display: grid; gap: 22px; } .wayfarer-start-section, .wayfarer-start-choice { margin: 0; padding: 18px; border: 1px solid #d8dbe2; border-radius: 9px; background: #ffffff; } .wayfarer-start-section h3, .wayfarer-start-choice legend { margin: 0 0 14px; color: #20242a; font-size: 1.05rem; font-weight: 700; } .wayfarer-start-choice legend { padding: 0 6px; } .wayfarer-start-names { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } .wayfarer-start-names label { display: grid; gap: 5px; font-weight: 600; } .wayfarer-start-names input { width: 100%; padding: 9px 10px; border: 1px solid #b8bdc7; border-radius: 6px; font: inherit; } .wayfarer-start-choice { display: grid; gap: 10px; } .wayfarer-start-option { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: start; padding: 12px 13px; border: 1px solid #d8dbe2; border-radius: 7px; cursor: pointer; } .wayfarer-start-option:hover { background: #f6f7f9; } .wayfarer-start-option input { margin-top: 4px; } .wayfarer-start-option-copy { display: grid; gap: 4px; } .wayfarer-start-option-copy > span { color: #667085; font-size: 0.92rem; } .wayfarer-start-actions { display: flex; justify-content: flex-end; } .wayfarer-start-button { padding: 10px 18px; border: 1px solid #244b88; border-radius: 7px; background: #2f5ea8; color: #ffffff; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-start-button:hover { background: #244b88; } @media (max-width: 700px) { .wayfarer-start-screen { width: 100%; } .wayfarer-start-section, .wayfarer-start-choice { padding: 14px; } } /* ------------------------------------------------------------------ PLAYER ACTION SCREEN WAYFARER_ACTION_SCREEN_V39 ------------------------------------------------------------------ */ .wayfarer-action-screen { margin-top: 14px; padding: 18px; border: 1px solid #d8dbe2; border-radius: 10px; background: #ffffff; box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); } .wayfarer-action-screen[hidden], .wayfarer-travel-controls[hidden] { display: none !important; } .wayfarer-action-heading { margin-bottom: 14px; } .wayfarer-action-heading h2 { margin: 0 0 4px; font-size: 1.25rem; } .wayfarer-action-heading p { margin: 0; color: #667085; } .wayfarer-action-list { display: grid; gap: 10px; } .wayfarer-action-button { width: 100%; padding: 12px 14px; border: 1px solid #214b86; border-radius: 8px; background: #245a9b; color: #ffffff; cursor: pointer; font: inherit; font-weight: 700; text-align: left; } .wayfarer-action-button:hover { background: #1e4c84; } .wayfarer-action-button:disabled { cursor: default; opacity: 0.55; } /* ------------------------------------------------------------------ IN-CARD ACTION UTILITY VIEWS WAYFARER_ACTION_UTILITY_VIEWS_V48 ------------------------------------------------------------------ */ .wayfarer-action-utility-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 2px; } .wayfarer-action-utility-button, .wayfarer-action-back-button { padding: 10px 12px; border: 1px solid #b8bdc7; border-radius: 7px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 650; } .wayfarer-action-utility-button { width: 100%; text-align: center; } .wayfarer-action-utility-button:hover, .wayfarer-action-back-button:hover { border-color: #98a2b3; background: #f6f7f9; } .wayfarer-action-subview[hidden], .wayfarer-action-heading[hidden], .wayfarer-action-list[hidden] { display: none !important; } .wayfarer-action-subview-heading { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 18px; } .wayfarer-action-subview-heading h2 { margin: 0; font-size: 1.25rem; } .wayfarer-action-back-button { flex: 0 0 auto; } .wayfarer-action-subview-body { min-height: 90px; } /* ------------------------------------------------------------------ ENTITY INVENTORY / STATUS PRESENTATION WAYFARER_ENTITY_PRESENTATION_V49 ------------------------------------------------------------------ */ .wayfarer-inventory-list, .wayfarer-party-list { display: grid; gap: 10px; } .wayfarer-inventory-item, .wayfarer-party-member, .wayfarer-status-section { padding: 13px 14px; border: 1px solid #d8dbe2; border-radius: 8px; background: #f8fafc; } .wayfarer-inventory-item-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; } .wayfarer-inventory-quantity { flex: 0 0 auto; color: #667085; font-size: 0.9rem; font-weight: 650; } .wayfarer-status-section + .wayfarer-status-section { margin-top: 12px; } .wayfarer-status-section > h3 { margin: 0 0 10px; font-size: 1rem; } .wayfarer-status-player-name { margin: 0; font-weight: 700; } .wayfarer-party-member { background: #ffffff; } .wayfarer-party-member h4 { margin: 0; font-size: 0.98rem; } /* WAYFARER_PLAYER_TRAITS_STATUS_V52 */ .wayfarer-player-member h4::after { content: "Player"; margin-left: 8px; color: #667085; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; } .wayfarer-status-values { margin: 10px 0 0; } .wayfarer-status-values > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; padding: 6px 0; border-top: 1px solid #e3e6eb; } .wayfarer-status-values dt { color: #4b5563; } .wayfarer-status-values dd { margin: 0; font-weight: 700; text-align: right; } .wayfarer-status-muted { margin: 8px 0 0; color: #667085; font-size: 0.88rem; } /* ------------------------------------------------------------------ ENTITY DETAIL VIEW WAYFARER_ENTITY_DETAIL_VIEW_V50 ------------------------------------------------------------------ */ .wayfarer-entity-open-button { appearance: none; border: 0; padding: 0; background: transparent; color: #2f5ea8; cursor: pointer; font: inherit; font-weight: 750; text-align: left; } .wayfarer-entity-open-button:hover { text-decoration: underline; } .wayfarer-entity-open-button:focus-visible { outline: 2px solid #2f5ea8; outline-offset: 3px; border-radius: 2px; } .wayfarer-status-player-names { display: grid; gap: 4px; } .wayfarer-status-player-names .wayfarer-status-player-name { margin: 0; } .wayfarer-entity-detail-eyebrow { margin: 0 0 3px; color: #667085; font-size: 0.78rem; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; } .wayfarer-entity-detail-layout { display: grid; grid-template-columns: minmax(180px, 280px) minmax(0, 1fr); gap: 22px; align-items: start; } .wayfarer-entity-detail-layout:not(:has(.wayfarer-entity-detail-media)) { grid-template-columns: minmax(0, 1fr); } .wayfarer-entity-detail-media { margin: 0; } .wayfarer-entity-detail-media img { display: block; width: 100%; max-height: 340px; object-fit: contain; border-radius: 8px; background: #f6f7f9; } .wayfarer-entity-detail-description { margin: 0 0 16px; line-height: 1.6; } .wayfarer-entity-detail-copy > :first-child { margin-top: 0; } @media (max-width: 700px) { .wayfarer-entity-detail-layout { grid-template-columns: 1fr; } .wayfarer-entity-detail-media img { max-height: 280px; } } .wayfarer-action-empty { margin: 0; color: #667085; font-size: 0.92rem; } .wayfarer-travel-controls { margin-top: 14px; padding: 12px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid #d8dbe2; border-radius: 10px; background: #ffffff; } .wayfarer-travel-status { display: flex; align-items: center; gap: 9px; font-weight: 700; } /* WAYFARER_TRAVEL_TIME_HUD_V61 */ .wayfarer-travel-copy { min-width: 0; } .wayfarer-travel-primary { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px; } .wayfarer-travel-time { font-weight: 800; } .wayfarer-travel-time-separator { color: #98a2b3; } .wayfarer-travel-next { margin-top: 3px; color: #667085; font-size: 0.88rem; font-weight: 600; } .wayfarer-travel-indicator { width: 9px; height: 9px; border-radius: 999px; background: currentColor; } .wayfarer-travel-pause { padding: 8px 12px; border: 1px solid #aab4c3; border-radius: 7px; background: #ffffff; color: #20242a; cursor: pointer; font: inherit; font-weight: 600; } .wayfarer-travel-pause:hover { background: #f6f7f9; } @media (max-width: 620px) { .wayfarer-action-screen { padding: 14px; } .wayfarer-travel-controls { align-items: stretch; flex-direction: column; } .wayfarer-travel-pause { width: 100%; } .wayfarer-action-utility-row { grid-template-columns: 1fr; } } /* ------------------------------------------------------------------ LOCATION / STORY PRESENTATION WAYFARER_LOCATION_ARRIVAL_PRESENTATION_V40 ------------------------------------------------------------------ */ /* * Scene prose now belongs to Presentation Mode rather than the normal Action * page. The server-rendered scene remains in the DOM temporarily for runtime * compatibility but is not player-facing. */ #wayfarer-player-scene { display: none; } .wayfarer-action-parent-context { margin-bottom: 4px !important; font-size: 0.85rem; font-weight: 600; } /* ------------------------------------------------------------------ STORYLET ACTIONS / BRANCHES WAYFARER_STORYLET_ACTION_RUNTIME_V59 ------------------------------------------------------------------ */ .wayfarer-storylet-action-list { display: grid; gap: 10px; } .wayfarer-storylet-action-list:empty { display: none; } .wayfarer-storylet-branch-list { width: 100%; display: grid; gap: 10px; } .wayfarer-presentation-actions.wayfarer-has-storylet-branches > div:not(.wayfarer-storylet-branch-list) { display: none; } .wayfarer-storylet-branch-prompt { margin: 0 0 4px; color: #b8c0cc; font-weight: 700; } .wayfarer-storylet-branch-button { width: 100%; padding: 12px 14px; border: 1px solid #f8fafc; border-radius: 8px; background: transparent; color: #f8fafc; cursor: pointer; font: inherit; font-weight: 700; text-align: left; } .wayfarer-storylet-branch-button:hover, .wayfarer-storylet-branch-button:focus-visible { background: #f8fafc; color: #15181d; } .wayfarer-storylet-branch-button:disabled { cursor: default; opacity: 0.55; } .wayfarer-presentation-screen { position: fixed; z-index: 10000; inset: 0; overflow-y: auto; padding: clamp(24px, 6vh, 72px) 24px; background: #15181d; color: #f8fafc; } .wayfarer-presentation-screen[hidden] { display: none !important; } .wayfarer-presentation-inner { width: min(1040px, 100%); min-height: calc(100vh - clamp(48px, 12vh, 144px)); margin: 0 auto; display: flex; flex-direction: column; justify-content: center; } .wayfarer-presentation-media { width: 100%; margin: 0 0 26px; overflow: hidden; border-radius: 12px; background: #0d0f12; } .wayfarer-presentation-media img { display: block; width: 100%; max-height: 52vh; object-fit: cover; } .wayfarer-presentation-copy { width: min(760px, 100%); margin: 0 auto; } .wayfarer-presentation-eyebrow { margin: 0 0 8px; color: #b8c0cc; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .wayfarer-presentation-copy h1 { margin: 0 0 18px; color: inherit; font-size: clamp(2rem, 5vw, 3.8rem); line-height: 1.05; } .wayfarer-presentation-description { font-size: clamp(1rem, 2vw, 1.22rem); line-height: 1.7; } .wayfarer-presentation-actions { width: min(760px, 100%); margin: 32px auto 0; display: flex; justify-content: flex-end; } .wayfarer-presentation-continue { min-width: 150px; padding: 11px 18px; border: 1px solid #f8fafc; border-radius: 8px; background: #f8fafc; color: #15181d; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-presentation-continue:hover { background: #e8edf3; } .wayfarer-presentation-continue:focus-visible { outline: 3px solid #ffffff; outline-offset: 4px; } .wayfarer-presentation-key-hint { margin: 10px 0 0; color: #9ea8b6; font-size: 0.78rem; text-align: right; } body.wayfarer-runtime-presentation { overflow: hidden; } @media (max-width: 620px) { .wayfarer-presentation-screen { padding: 20px 14px; } .wayfarer-presentation-inner { min-height: calc(100vh - 40px); } .wayfarer-presentation-actions { justify-content: stretch; } .wayfarer-presentation-continue { width: 100%; } } /* ------------------------------------------------------------------ COMBINED AUTHOR STATE + REGION WAYFARER_COMBINED_AUTHOR_STATE_REGION_V41 ------------------------------------------------------------------ */ .wayfarer-author-state-region { margin-top: 20px; padding-top: 14px; border-top: 1px solid #e4e7ec; } .wayfarer-author-state-region > :first-child { margin-top: 0; } /* ------------------------------------------------------------------ TERMINAL GAME ENDING WAYFARER_GAME_ENDING_SCREEN_V45 ------------------------------------------------------------------ */ .wayfarer-ending-screen { position: fixed; z-index: 11000; inset: 0; overflow-y: auto; padding: clamp(24px, 6vh, 72px) 24px; background: #11151a; color: #f8fafc; } .wayfarer-ending-inner { width: min(1040px, 100%); min-height: calc(100vh - clamp(48px, 12vh, 144px)); margin: 0 auto; display: flex; flex-direction: column; justify-content: center; } .wayfarer-ending-media { width: 100%; margin: 0 0 28px; overflow: hidden; border-radius: 12px; background: #080a0d; } .wayfarer-ending-media img { display: block; width: 100%; max-height: 52vh; object-fit: cover; } .wayfarer-ending-copy { width: min(760px, 100%); margin: 0 auto; } .wayfarer-ending-eyebrow { margin: 0 0 8px; color: #aeb8c5; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .wayfarer-ending-copy h1 { margin: 0 0 18px; color: inherit; font-size: clamp(2rem, 5vw, 3.8rem); line-height: 1.05; } .wayfarer-ending-text { font-size: clamp(1rem, 2vw, 1.22rem); line-height: 1.7; } body.wayfarer-game-ended { overflow: hidden; } /* ------------------------------------------------------------------ PRESENTATION TRANSITION + ENDING RESTART WAYFARER_PRESENTATION_TRANSITION_V63 WAYFARER_ENDING_RESTART_V63 ------------------------------------------------------------------ */ body.wayfarer-presentation-transitioning::after { content: ""; position: fixed; z-index: 10500; inset: 0; background: #15181d; pointer-events: all; } .wayfarer-ending-actions { margin-top: 30px; display: flex; justify-content: flex-end; } .wayfarer-ending-restart { min-width: 150px; padding: 11px 18px; border: 1px solid #f8fafc; border-radius: 8px; background: #f8fafc; color: #15181d; cursor: pointer; font: inherit; font-weight: 700; } .wayfarer-ending-restart:hover { background: #e8edf3; } .wayfarer-ending-restart:focus-visible { outline: 3px solid #ffffff; outline-offset: 4px; } .wayfarer-ending-restart:disabled { cursor: default; opacity: 0.6; } @media (max-width: 620px) { .wayfarer-ending-actions { justify-content: stretch; } .wayfarer-ending-restart { width: 100%; } } /* ------------------------------------------------------------------ PERSISTENT PLAYER HUD WAYFARER_PLAYER_HUD_V64 ------------------------------------------------------------------ */ .wayfarer-player-pane { display: flex; flex-direction: column; } .wayfarer-player-hud { flex: 0 0 auto; margin-top: auto; padding-top: 18px; } .wayfarer-player-hud[hidden] { display: none !important; } .wayfarer-hud-components { display: grid; grid-template-columns: repeat( auto-fit, minmax( min(260px, 100%), 1fr ) ); gap: 12px 18px; padding: 14px 16px; border: 1px solid #d8dbe2; border-radius: 10px; background: #f8fafc; } .wayfarer-hud-progress-component { min-width: 0; } .wayfarer-hud-progress-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 7px; } .wayfarer-hud-label { min-width: 0; overflow: hidden; font-size: 0.88rem; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } .wayfarer-hud-value { flex: 0 0 auto; color: #667085; font-size: 0.82rem; font-variant-numeric: tabular-nums; } .wayfarer-hud-progress-track { height: 10px; overflow: hidden; border-radius: 999px; background: #dfe4ea; } .wayfarer-hud-progress-fill { height: 100%; min-width: 0; border-radius: inherit; background: #2f5ea8; transition: width 180ms ease; } @media (max-width: 620px) { .wayfarer-player-hud { padding-top: 14px; } .wayfarer-hud-components { grid-template-columns: 1fr; gap: 12px; padding: 12px 13px; } } /* ------------------------------------------------------------------ REGION HUD AUTHORING WAYFARER_REGION_HUD_EDITOR_V64 ------------------------------------------------------------------ */ .builder-region-hud { margin: 0; } .builder-region-hud > h3 { margin-top: 0; } .builder-region-hud-component { margin: 14px 0; padding: 14px 16px; border: 1px solid #d8dbe2; border-radius: 8px; } .builder-region-hud-component legend { padding: 0 6px; font-weight: 700; } .builder-region-hud-component select, .builder-region-hud-component input[type="text"] { width: min(560px, 100%); max-width: 100%; } ================================================================================ FILE: update-backups/v70-20260926-031619/play/includes/effects.php ================================================================================ true, 'value' => wayfarerNormalizeRuntimeNumber( (float)$effectValue ), 'error' => '', ]; } return [ 'applied' => true, 'value' => $effectValue, 'error' => '', ]; } if ( $operation !== 'add' && $operation !== 'subtract' ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Unsupported operation: ' . $operation, ]; } if ( !is_numeric($currentValue) || !is_numeric($effectValue) ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Add/subtract requires numeric values.', ]; } $current = (float)$currentValue; $amount = (float)$effectValue; $result = $operation === 'add' ? $current + $amount : $current - $amount; return [ 'applied' => true, 'value' => wayfarerNormalizeRuntimeNumber( $result ), 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Tick Effect branch runtime |-------------------------------------------------------------------------- | | A Tick Effect owns one target and an ordered list of result branches. | Conditions are evaluated against the CURRENT runtime state. Because Tick | Effects execute sequentially, a later Tick Effect can see state changes made | by an earlier Tick Effect during the same tick. | */ function wayfarerRuntimeResolveConditionTarget( array $runtimeState, int $currentRegionId, string $scope, string $targetType, string $targetId ): array { $scope = strtolower( trim( $scope ) ); $targetType = strtolower( trim( $targetType ) ); $targetId = trim( $targetId ); // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $targetType === 'trait' && $targetId !== '' ) { $traits = $runtimeState['game']['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // SPECIFIC REGION TRAIT // WAYFARER_SPECIFIC_REGION_CONDITION_SCOPE_V46 // ------------------------------------------------------------ // // Game-level condition editors store a Region-owned Trait as // "region:". This lets a global rule inspect that Region whether // or not the player is currently there. // if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^region:([1-9][0-9]*)$/', $scope, $regionScopeMatch ) === 1 ) { $regionKey = (string)( (int)$regionScopeMatch[1] ); $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $targetType === 'trait' && $targetId !== '' && $currentRegionId > 0 ) { $regionKey = (string)$currentRegionId; $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // PLAYER / LOCATION / ENTITY TRAITS // WAYFARER_RUNTIME_UNIVERSAL_TARGET_RESOLVER_V56 // ------------------------------------------------------------ if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^player:([0-9]+)$/', $scope, $playerScopeMatch ) === 1 ) { $playerKey = (string)((int)$playerScopeMatch[1]); $traits = $runtimeState['players'][$playerKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^location:([1-9][0-9]*)$/', $scope, $locationScopeMatch ) === 1 ) { $locationKey = (string)((int)$locationScopeMatch[1]); $traits = $runtimeState['locations'][$locationKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityScopeMatch ) === 1 ) { $entityKey = (string)((int)$entityScopeMatch[1]); $traits = $runtimeState['entities'][$entityKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $targetType === 'position' && $targetId === 'position' ) { if ( isset( $runtimeState['navigation'] ) && is_array( $runtimeState['navigation'] ) && array_key_exists( 'position', $runtimeState['navigation'] ) ) { return [ 'found' => true, 'value' => $runtimeState['navigation']['position'], ]; } return [ 'found' => false, 'value' => null, ]; } return [ 'found' => false, 'value' => null, ]; } function wayfarerRuntimeTickEffectBranches( array $effect ): array { /* * v28+ canonical structure. * * If the branches key exists, it is authoritative. An empty branches * array therefore means "this effect has no executable results" rather * than silently falling back to legacy compatibility fields. */ if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * Legacy pre-v28 structure. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'conditions', $effect ) ) { $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $effect['operation'] ?? '', 'value_mode' => $effect['value_mode'] ?? 'fixed', ]; if ( strtolower( trim( (string)$branch['value_mode'] ) ) === 'random' ) { $branch['value_min'] = $effect['value_min'] ?? 0; $branch['value_max'] = $effect['value_max'] ?? 0; } else { $branch['value'] = $effect['value'] ?? 0; } return [ $branch ]; } return []; } function wayfarerRuntimeSelectTickEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeTickEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Tick Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; /* * Validate before evaluating. Invalid authored data fails closed and * does NOT fall through to OTHERWISE, because doing so could silently * produce the wrong gameplay result. */ $validation = wayfarerConditionValidateNode( $conditions ); if (!$validation['valid']) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Tick Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Runtime Trait bounds |-------------------------------------------------------------------------- | | WAYFARER_RUNTIME_TRAIT_BOUNDS_V30 | | Authored numeric Trait min/max values are runtime invariants. | State is normalized before condition evaluation and every subsequent | mutation is clamped again before it is written. | */ function wayfarerRuntimeDecodeTraitConfig( $rawConfig ): array { if (is_array($rawConfig)) { return $rawConfig; } if ( $rawConfig === null || $rawConfig === '' ) { return []; } $decoded = json_decode( (string)$rawConfig, true ); return is_array($decoded) ? $decoded : []; } function wayfarerRuntimeIndexTraitDefinitions( array $traits ): array { $definitions = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $definitions[$traitId] = $trait; } return $definitions; } function wayfarerRuntimeLoadTickTraitDefinitions( PDO $pdo, int $gameId, array $regionConfig ): array { $gameConfig = []; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawGameConfig = $stmt->fetchColumn(); if ($rawGameConfig !== false) { $gameConfig = wayfarerRuntimeDecodeTraitConfig( $rawGameConfig ); } } /* WAYFARER_RUNTIME_UNIVERSAL_DEFINITIONS_V56 */ $definitions = [ 'game' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['traits'] ?? null ) ? $gameConfig['traits'] : [] ), 'player' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['player_traits'] ?? null ) ? $gameConfig['player_traits'] : [] ), 'region' => wayfarerRuntimeIndexTraitDefinitions( is_array( $regionConfig['traits'] ?? null ) ? $regionConfig['traits'] : [] ), 'regions' => [], 'locations' => [], 'entities' => [], ]; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $definitions['regions'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } $stmt = $pdo->prepare( " SELECT l.id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $definitions['locations'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } $stmt = $pdo->prepare( " SELECT id, entity_type, config FROM wf_entities WHERE game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); if ( function_exists( 'wayfarerEntityActiveTraitDefinitions' ) ) { $definitions['entities'][$id] = wayfarerEntityActiveTraitDefinitions( $config, (string)( $row['entity_type'] ?? '' ) ); } else { $definitions['entities'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } } } return $definitions; } function wayfarerRuntimeClampValueToTraitDefinition( $value, ?array $definition ): array { $result = [ 'valid' => true, 'value' => $value, 'clamped' => false, 'min' => null, 'max' => null, 'error' => '', ]; /* * Bounds currently belong only to authored Number Traits. */ if ( !$definition || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' ) { return $result; } if (!is_numeric($value)) { $result['valid'] = false; $result['error'] = 'Numeric Trait runtime state must be numeric.'; return $result; } $min = isset($definition['min']) && $definition['min'] !== '' && is_numeric($definition['min']) ? (float)$definition['min'] : null; $max = isset($definition['max']) && $definition['max'] !== '' && is_numeric($definition['max']) ? (float)$definition['max'] : null; $numeric = (float)$value; $bounded = $numeric; if ( $min !== null && $bounded < $min ) { $bounded = $min; } if ( $max !== null && $bounded > $max ) { $bounded = $max; } $result['value'] = wayfarerNormalizeRuntimeNumber( $bounded ); $result['clamped'] = $bounded !== $numeric; $result['min'] = $min === null ? null : wayfarerNormalizeRuntimeNumber( $min ); $result['max'] = $max === null ? null : wayfarerNormalizeRuntimeNumber( $max ); return $result; } function wayfarerRuntimeApplyBoundedTraitOperation( $storedBefore, string $operation, $effectValue, ?array $definition ): array { /* * Repair an already-illegal persisted value BEFORE applying the new * operation. Example: 120 with max 100 and subtract 10 becomes * 100 - 10 = 90, not 120 - 10 = 110 -> 100. */ $beforeBound = wayfarerRuntimeClampValueToTraitDefinition( $storedBefore, $definition ); if (!$beforeBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $storedBefore, 'requested_after' => $storedBefore, 'after' => $storedBefore, 'clamped' => false, 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $beforeBound['error'], ]; } $normalizedBefore = $beforeBound['value']; $operationResult = wayfarerApplyRuntimeOperation( $normalizedBefore, $operation, $effectValue ); if (!$operationResult['applied']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $normalizedBefore, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $operationResult['error'], ]; } $requestedAfter = $operationResult['value']; $afterBound = wayfarerRuntimeClampValueToTraitDefinition( $requestedAfter, $definition ); if (!$afterBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $afterBound['error'], ]; } return [ 'applied' => true, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $afterBound['value'], 'clamped' => $beforeBound['clamped'] || $afterBound['clamped'], 'min' => $afterBound['min'], 'max' => $afterBound['max'], 'error' => '', ]; } function wayfarerRuntimeClampApplicableTraitState( array &$runtimeState, array $traitDefinitions, int $currentRegionId ): array { $repairs = []; // ------------------------------------------------------------ // GAME TRAITS // ------------------------------------------------------------ $gameTraits = $runtimeState['game']['traits'] ?? null; if (is_array($gameTraits)) { foreach ( $traitDefinitions['game'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $gameTraits ) ) { continue; } $before = $runtimeState['game']['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['game']['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } // ------------------------------------------------------------ // CURRENT REGION TRAITS // ------------------------------------------------------------ $regionKey = (string)$currentRegionId; $regionTraits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if (is_array($regionTraits)) { foreach ( $traitDefinitions['region'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $regionTraits ) ) { continue; } $before = $runtimeState['regions'][$regionKey]['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } return $repairs; } function wayfarerApplyRuntimeTickEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectTickEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection['branch_index'], 'applied' => false, 'error' => $selection['error'], ]; } /* * No conditional branch matched and there was no OTHERWISE. * This is a valid "do nothing this tick" result. */ if (!$selection['matched']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection['branch_index']; $operation = strtolower( trim( (string)( $branch['operation'] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; /* * Explicit No Change: * the branch DID match, so this Tick Effect stops here, but state does * not mutate. */ if ($operation === 'none') { $current = wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $type, $targetId ); return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'amount' => null, 'before' => $current['found'] ? $current['value'] : null, 'after' => $current['found'] ? $current['value'] : null, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'applied' => false, 'no_change' => true, 'error' => '', ]; } /* WAYFARER_TICK_EFFECT_RANDOM_RUNTIME_V58 */ $resolvedValue = wayfarerRuntimeRollEffectValue( $branch ); if (!$resolvedValue['valid']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'applied' => false, 'error' => $resolvedValue['error'], ]; } /* * Apply ONLY the selected result branch. The legacy effect-level * operation/value fields are deliberately ignored when branches exist. */ $resolvedEffect = $effect; $resolvedEffect['operation'] = $operation; $resolvedEffect['value'] = $resolvedValue['value']; $report = wayfarerApplyRuntimeEffect( $runtimeState, $resolvedEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; $report['value_mode'] = $resolvedValue['mode']; $report['rolled_value'] = $resolvedValue['value']; if ($resolvedValue['mode'] === 'random') { $report['random_min'] = $resolvedValue['min']; $report['random_max'] = $resolvedValue['max']; } return $report; } function wayfarerApplyRuntimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); $operation = strtolower( trim( (string)( $effect['operation'] ?? '' ) ) ); $effectValue = $effect['value'] ?? 0; // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $type === 'position' && $targetId === 'position' ) { if ( !isset($runtimeState['navigation']) || !is_array($runtimeState['navigation']) ) { $runtimeState['navigation'] = []; } $before = $runtimeState['navigation']['position'] ?? 0; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); $after = $before; if ( $result['applied'] && !is_numeric($result['value']) ) { $result['applied'] = false; $result['error'] = 'Navigation position must be numeric.'; } if ($result['applied']) { /* * Global navigation rule: * position may never be less than zero. */ $after = max( 0, $result['value'] ); $runtimeState['navigation']['position'] = $after; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $after, 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['game']) || !is_array($runtimeState['game']) ) { $runtimeState['game'] = []; } if ( !isset($runtimeState['game']['traits']) || !is_array($runtimeState['game']['traits']) ) { $runtimeState['game']['traits'] = []; } $before = $runtimeState['game']['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['game'][$targetId] ) && is_array( $traitDefinitions['game'][$targetId] ) ? $traitDefinitions['game'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); /* * Persist the normalized value even when a malformed operation fails * after an existing illegal value was repaired. */ if ( $result['normalized_before'] !== $before ) { $runtimeState['game']['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['game']['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // ITEM ENTITY QUANTITY // WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 // ------------------------------------------------------------ if ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityScopeMatch ) === 1 && ( (string)( $effect['target_system_key'] ?? '' ) === 'quantity' || $targetId === 'sys_item_quantity' ) ) { $entityKey = (string)( (int)$entityScopeMatch[1] ); if ( !isset( $runtimeState['entities'][$entityKey] ) || !is_array( $runtimeState['entities'][$entityKey] ) || !isset( $runtimeState['entities'][$entityKey]['traits'] ) || !is_array( $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Entity runtime state was not initialized.', ]; } if ( !array_key_exists( $targetId, $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Quantity runtime state was not found.', ]; } $before = $runtimeState['entities'][$entityKey]['traits'][$targetId]; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); if ( !$result['applied'] || !is_numeric( $result['value'] ?? null ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $before, 'applied' => false, 'error' => $result['error'] !== '' ? $result['error'] : 'Item Quantity must remain numeric.', ]; } $requestedAfter = wayfarerNormalizeRuntimeNumber( (float)$result['value'] ); /* * Quantity is globally non-negative. */ $after = wayfarerNormalizeRuntimeNumber( max( 0.0, (float)$requestedAfter ) ); $runtimeState['entities'][$entityKey]['traits'][$targetId] = $after; /* * Discovery is persistent. Receiving any positive starting quantity * discovers the Item; setting it back to zero never undiscoveres it. */ if ( (float)$after > 0 ) { $runtimeState['entities'][$entityKey]['discovered'] = true; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_system_key' => 'quantity', 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'requested_after' => $requestedAfter, 'after' => $after, 'clamped' => (float)$after !== (float)$requestedAfter, 'min' => 0, 'max' => null, 'applied' => true, 'error' => '', ]; } // ------------------------------------------------------------ // SPECIFIC PLAYER / REGION / LOCATION / ENTITY TRAITS // WAYFARER_RUNTIME_UNIVERSAL_EFFECT_TARGETS_V56 // ------------------------------------------------------------ $stateBucket = null; $stateKey = null; $definition = null; if ( $type === 'trait' && $targetId !== '' && preg_match( '/^player:([0-9]+)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'players'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['player'][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^region:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'regions'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['regions'][$stateKey][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^location:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'locations'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['locations'][$stateKey][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 && $targetId !== 'sys_item_quantity' ) { $stateBucket = 'entities'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['entities'][$stateKey][$targetId] ?? null; } if ( $stateBucket !== null && $stateKey !== null ) { if ( !isset($runtimeState[$stateBucket][$stateKey]) || !is_array($runtimeState[$stateBucket][$stateKey]) || !isset($runtimeState[$stateBucket][$stateKey]['traits']) || !is_array($runtimeState[$stateBucket][$stateKey]['traits']) || !array_key_exists( $targetId, $runtimeState[$stateBucket][$stateKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Runtime Trait target was not found.', ]; } $before = $runtimeState[$stateBucket][$stateKey]['traits'][$targetId]; /* * Party Membership is mutable runtime state, but still a binary * Wayfarer system property. The 0/1 rule is enforced internally and is * never exposed as a separate Target/max metadata value. */ if ( $stateBucket === 'entities' && $targetId === 'sys_character_party_member' ) { $operationResult = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); if ( !$operationResult['applied'] || !is_numeric( $operationResult['value'] ?? null ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $before, 'applied' => false, 'error' => $operationResult['error'] !== '' ? $operationResult['error'] : 'Party Membership requires a numeric 0/1 result.', ]; } $after = (float)$operationResult['value'] > 0 ? 1 : 0; $runtimeState[$stateBucket][$stateKey]['traits'][$targetId] = $after; return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_system_key' => 'party_member', 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $after, 'applied' => true, 'error' => '', ]; } $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, is_array($definition) ? $definition : null ); if ($result['applied']) { $runtimeState[$stateBucket][$stateKey]['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // PLAYER TRAIT // WAYFARER_STARTING_CHOICE_PLAYER_TARGETS_V53 // ------------------------------------------------------------ if ( $scope === 'player' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['players']) || !is_array($runtimeState['players']) ) { $runtimeState['players'] = []; } $selector = strtolower( trim( (string)( $effect['target_selector'] ?? '' ) ) ); $targetKeys = []; if ($selector === 'all') { foreach ( $runtimeState['players'] as $playerKey => $playerState ) { if (is_array($playerState)) { $targetKeys[] = (string)$playerKey; } } } elseif ( $selector === 'slot' && array_key_exists( 'target_slot', $effect ) ) { $slot = max( 0, (int)$effect['target_slot'] ); $targetKeys[] = (string)$slot; } else { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect requires an All Players or Player Slot target.', ]; } if (empty($targetKeys)) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect did not resolve any Player Characters.', ]; } $definition = isset( $traitDefinitions['player'][$targetId] ) && is_array( $traitDefinitions['player'][$targetId] ) ? $traitDefinitions['player'][$targetId] : null; $results = []; $firstError = ''; foreach ($targetKeys as $playerKey) { if ( !isset( $runtimeState['players'][$playerKey] ) || !is_array( $runtimeState['players'][$playerKey] ) ) { $firstError = 'Player Character slot ' . ((int)$playerKey + 1) . ' does not exist in runtime state.'; $results[$playerKey] = [ 'applied' => false, 'error' => $firstError, ]; break; } if ( !isset( $runtimeState['players'][$playerKey]['traits'] ) || !is_array( $runtimeState['players'][$playerKey]['traits'] ) ) { $runtimeState['players'][$playerKey]['traits'] = []; } $before = $runtimeState['players'][$playerKey]['traits'][$targetId] ?? 0; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['after']; } $results[$playerKey] = [ 'slot' => (int)$playerKey, 'name' => (string)( $runtimeState['players'][$playerKey]['name'] ?? ( 'Player ' . ((int)$playerKey + 1) ) ), 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; if ( !$result['applied'] || $result['error'] !== '' ) { $firstError = $result['error'] !== '' ? $result['error'] : 'Player Trait effect could not be applied.'; break; } } $allApplied = $firstError === ''; return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_selector' => $selector, 'target_slot' => $selector === 'slot' ? (int)( $effect['target_slot'] ?? 0 ) : null, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'results' => $results, 'applied' => $allApplied, 'error' => $firstError, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $type === 'trait' && $targetId !== '' ) { $regionKey = (string)$currentRegionId; if ( !isset($runtimeState['regions']) || !is_array($runtimeState['regions']) ) { $runtimeState['regions'] = []; } if ( !isset($runtimeState['regions'][$regionKey]) || !is_array($runtimeState['regions'][$regionKey]) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset($runtimeState['regions'][$regionKey]['traits']) || !is_array($runtimeState['regions'][$regionKey]['traits']) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } $before = $runtimeState['regions'][$regionKey]['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['region'][$targetId] ) && is_array( $traitDefinitions['region'][$targetId] ) ? $traitDefinitions['region'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Unsupported runtime target.', ]; } /* |-------------------------------------------------------------------------- | Game Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_GAME_TICK_EFFECT_RUNTIME_V33 | | Game Tick Effects execute once on every successful runtime tick before | the active Region's Tick Effects. This creates a deterministic layering: | | Game rules -> Region rules | */ function wayfarerApplyGameTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { if ($gameId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active game is available for Game Tick Effects.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { $reports = [ [ 'applied' => false, 'error' => 'Game ' . $gameId . ' was not found.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $gameConfig = []; if ( $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, [] ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $gameEffects = $gameConfig['tick_effects'] ?? []; if (!is_array($gameEffects)) { $gameEffects = []; } $reports = []; foreach ($gameEffects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Game Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; $runtimeState['runtime']['last_game_trait_bound_repairs'] = $boundRepairs; return $reports; } /* |-------------------------------------------------------------------------- | Generalized one-time Effect runtime | WAYFARER_GENERALIZED_EFFECT_RUNTIME_V43 |-------------------------------------------------------------------------- | | Conditions use the same shared evaluator as Tick Effect branches. | Random values are resolved exactly once, immediately before the selected | result mutates runtime state. | */ function wayfarerRuntimeEffectBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * v42 compatibility: one Effect was one unconditional operation/value. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $branch = [ 'conditions' => null, 'operation' => $effect['operation'] ?? 'add', 'value_mode' => $effect['value_mode'] ?? 'fixed', ]; if ( strtolower( trim( (string)( $branch[ 'value_mode' ] ) ) ) === 'random' ) { $branch['value_min'] = $effect['value_min'] ?? 0; $branch['value_max'] = $effect['value_max'] ?? 0; } else { $branch['value'] = $effect['value'] ?? 0; } return [ $branch ]; } return []; } function wayfarerRuntimeSelectEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; $validation = wayfarerConditionValidateNode( $conditions ); if ( !$validation['valid'] ) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } function wayfarerRuntimeRollEffectValue( array $branch ): array { $mode = strtolower( trim( (string)( $branch[ 'value_mode' ] ?? 'fixed' ) ) ); if ($mode !== 'random') { $value = $branch['value'] ?? null; /* WAYFARER_FIXED_SCALAR_EFFECT_RUNTIME_V58 */ return [ 'valid' => true, 'mode' => 'fixed', 'value' => is_numeric($value) ? wayfarerNormalizeRuntimeNumber( (float)$value ) : $value, 'min' => null, 'max' => null, 'error' => '', ]; } $minimum = $branch['value_min'] ?? null; $maximum = $branch['value_max'] ?? null; if ( !is_numeric($minimum) || !is_numeric($maximum) ) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => $minimum, 'max' => $maximum, 'error' => 'Random Effect requires numeric Min and Max values.', ]; } $min = (float)$minimum; $max = (float)$maximum; if ($min > $max) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => 'Random Effect Min cannot be greater than Max.', ]; } if ( floor($min) === $min && floor($max) === $max ) { $rolled = random_int( (int)$min, (int)$max ); } else { /* * Continuous decimal range. Six decimal places of entropy are more * than sufficient for authored simulation values and preserve generic * numeric Min/Max support without requiring authored precision metadata. */ $fraction = random_int( 0, 1000000 ) / 1000000; $rolled = $min + (($max - $min) * $fraction); } return [ 'valid' => true, 'mode' => 'random', 'value' => wayfarerNormalizeRuntimeNumber( (float)$rolled ), 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => '', ]; } function wayfarerApplyRuntimeOneTimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect[ 'target_scope' ] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect[ 'target_type' ] ?? '' ) ) ); $targetId = trim( (string)( $effect[ 'target_id' ] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection[ 'branch_index' ], 'applied' => false, 'error' => $selection[ 'error' ], ]; } if ( !$selection['matched'] ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection[ 'branch_index' ]; $operation = strtolower( trim( (string)( $branch[ 'operation' ] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; if ($operation === 'none') { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => null, 'rolled_value' => null, 'applied' => false, 'error' => '', ]; } $resolved = wayfarerRuntimeRollEffectValue( $branch ); if (!$resolved['valid']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => $resolved[ 'mode' ], 'rolled_value' => null, 'random_min' => $resolved[ 'min' ], 'random_max' => $resolved[ 'max' ], 'applied' => false, 'error' => $resolved[ 'error' ], ]; } $concreteEffect = [ 'target_scope' => $scope, 'target_type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'value' => $resolved[ 'value' ], ]; $report = wayfarerApplyRuntimeEffect( $runtimeState, $concreteEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; $report['value_mode'] = $resolved[ 'mode' ]; $report['rolled_value'] = $resolved[ 'value' ]; if ( $resolved['mode'] === 'random' ) { $report['random_min'] = $resolved[ 'min' ]; $report['random_max'] = $resolved[ 'max' ]; } return $report; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECT_RUNTIME_V42 | | These are event Effects, not Tick Effects. The caller decides that a | Location has been triggered and invokes this function exactly once for that | trigger. Effects execute top-to-bottom against current runtime state. | */ function wayfarerApplyLocationEffects( PDO $pdo, int $gameId, array $location, array &$runtimeState ): array { $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeTraitConfig( $location['config'] ?? null ); $effects = $locationConfig['effects'] ?? []; if (!is_array($effects)) { $effects = []; } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false ) { $regionConfig = wayfarerRuntimeDecodeTraitConfig( $rawRegionConfig ); } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeOneTimeEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_location_effects'] = [ 'location_id' => $locationId, 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), 'reports' => $reports, 'bound_repairs' => $boundRepairs, ]; return $reports; } /* |-------------------------------------------------------------------------- | Current Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECT_RUNTIME_V35 | | Linear Regions resolve a current Location by exact authored Position, | matching the player-scene resolver. Location Tick Effects run after Region | Tick Effects, so movement performed by the Region can cause arrival effects | at the newly reached Location on the same tick. | */ function wayfarerRuntimeFindCurrentLocation( PDO $pdo, int $gameId, array $runtimeState ): ?array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ( $gameId <= 0 || $regionId <= 0 ) { return null; } $position = $runtimeState['navigation']['position'] ?? null; if (!is_numeric($position)) { return null; } $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($locations as $location) { $config = []; if ( !empty( $location['config'] ) ) { $decoded = json_decode( (string)$location['config'], true ); if (is_array($decoded)) { $config = $decoded; } } if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } if ( (float)$config['position'] === (float)$position ) { $location['_runtime_config'] = $config; /* * WAYFARER_LOCATION_CONDITIONS_CURRENT_V45 */ if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } return $location; } } return null; } function wayfarerApplyCurrentLocationTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } /* * Being between authored Locations is normal. Record an empty result * rather than treating the absence of a Location as an error. */ if (!$location) { $runtimeState['runtime']['last_location_id'] = null; $runtimeState['runtime']['last_location_tick_effects'] = []; return []; } $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : []; $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); /* * Location effects may target Current Region Traits, so load the active * Region's Trait definitions rather than the Location's authored Traits. */ $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false && $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $locationEffects = $locationConfig['tick_effects'] ?? []; if (!is_array($locationEffects)) { $locationEffects = []; } $reports = []; foreach ( $locationEffects as $effect ) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } $runtimeState['runtime']['last_location_id'] = $locationId; $runtimeState['runtime']['last_location_tick_effects'] = $reports; $runtimeState['runtime']['last_location_trait_bound_repairs'] = $boundRepairs; return $reports; } function wayfarerApplyCurrentRegionTickEffects( PDO $pdo, array &$runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ($regionId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active navigation region.', ] ]; /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Reports are returned for the current request, not persisted. */ return $reports; } $stmt = $pdo->prepare( " SELECT id, game_id, config FROM wf_regions WHERE id = ? LIMIT 1 " ); $stmt->execute([ $regionId ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { $reports = [ [ 'applied' => false, 'error' => 'Active Region ' . $regionId . ' was not found.', ] ]; /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Reports are returned for the current request, not persisted. */ return $reports; } $config = []; if (!empty($region['config'])) { $decoded = json_decode( $region['config'], true ); if (is_array($decoded)) { $config = $decoded; } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, (int)( $region['game_id'] ?? 0 ), $config ); /* * Enforce the invariant before any branch condition is evaluated. * This also repairs state produced by older runtime versions. */ $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $regionId ); $effects = $config['tick_effects'] ?? []; if (!is_array($effects)) { $effects = []; } $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed tick effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $regionId, $traitDefinitions ); } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Tick reports and bound-repair reports are immediate diagnostics only. */ return $reports; } ================================================================================ FILE: update-backups/v70-20260926-031619/play/includes/presentation-screen.php ================================================================================ prepare( " SELECT l.*, r.game_id, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE l.id = ? AND r.game_id = ? LIMIT 1 " ); $stmt->execute([ $presentationId, (int)$gameId, ]); $presentationRecord = $stmt->fetch( PDO::FETCH_ASSOC ); if ($presentationRecord) { /* * Media links use the authored table name as object_type. */ $mediaStmt = $pdo->prepare( " SELECT m.id, m.name, m.relative_path, m.mime_type FROM wf_media_links ml INNER JOIN wf_media m ON m.id = ml.media_id WHERE ml.object_type = 'wf_locations' AND ml.object_id = ? AND ml.slot = 'image' AND m.game_id = ? AND m.media_type = 'image' LIMIT 1 " ); $mediaStmt->execute([ $presentationId, (int)$gameId, ]); $presentationImage = $mediaStmt->fetch( PDO::FETCH_ASSOC ); } } /* * --------------------------------------------------------------- * STORYLET * WAYFARER_STORYLET_PRESENTATION_V59 * --------------------------------------------------------------- */ if ( $presentationType === 'storylet' ) { $presentationRecord = wayfarerRuntimeLoadStorylet( $pdo, (int)$gameId, $presentationId ); if ($presentationRecord) { $mediaStmt = $pdo->prepare( " SELECT m.id, m.name, m.relative_path, m.mime_type FROM wf_media_links ml INNER JOIN wf_media m ON m.id = ml.media_id WHERE ml.object_type = 'wf_storylets' AND ml.object_id = ? AND ml.slot = 'image' AND m.game_id = ? AND m.media_type = 'image' LIMIT 1 " ); $mediaStmt->execute([ $presentationId, (int)$gameId, ]); $presentationImage = $mediaStmt->fetch( PDO::FETCH_ASSOC ); /* * Conditions are evaluated AFTER the current Storylet's Effects have * executed, because entry persisted the mutated state first. */ $presentationBranches = wayfarerRuntimeEligibleStoryletBranches( $pdo, (int)$gameId, $presentationId, $state ); } } if (!$presentationRecord) { /* * Keep the player from becoming trapped if an authored object is removed * after its presentation state was persisted. */ $presentationRecord = [ 'name' => 'Continue', 'description' => 'This presentation is no longer available.', ]; } $presentationName = trim( (string)( $presentationRecord['name'] ?? '' ) ); if ($presentationName === '') { $presentationName = $presentationType === 'storylet' ? 'Storylet' : 'Location'; } $presentationDescription = trim( (string)( $presentationRecord['description'] ?? '' ) ); $presentationImageUrl = ''; if ( is_array($presentationImage) && !empty( $presentationImage['relative_path'] ) ) { $relativePath = ltrim( str_replace( '\\', '/', (string)$presentationImage[ 'relative_path' ] ), '/' ); /* * Media uploads are stored beneath the Wayfarer root. play/ is one level * below that root. */ if ( $relativePath !== '' && strpos( $relativePath, '..' ) === false ) { $presentationImageUrl = '../' . $relativePath; } } ?> ================================================================================ FILE: update-backups/v70-20260926-031619/play/includes/storylets.php ================================================================================ root Manual Storylet -> Storylet presentation | -> eligible child Branch -> child presentation -> ... | | Automatic/Chance firing is intentionally not handled here yet. | */ function wayfarerRuntimeLoadStorylet( PDO $pdo, int $gameId, int $storyletId ): ?array { if ($gameId <= 0 || $storyletId <= 0) { return null; } $stmt = $pdo->prepare( " SELECT id, game_id, name, description, parent_storylet_id, branch_label, branch_sort_order, trigger_type, max_occurrences, status, config FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $storyletId, $gameId, ]); $storylet = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$storylet) { return null; } $storylet['_runtime_config'] = wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); return $storylet; } function wayfarerRuntimeStoryletOccurrenceCount( array $runtimeState, int $storyletId ): int { if ($storyletId <= 0) { return 0; } return max( 0, (int)( $runtimeState['storylets'][(string)$storyletId]['occurrences'] ?? 0 ) ); } function wayfarerRuntimeStoryletOccurrenceLimitReached( array $storylet, array $runtimeState ): bool { $maximum = $storylet['max_occurrences'] ?? null; if ( $maximum === null || $maximum === '' || !is_numeric($maximum) ) { return false; } $maximum = (int)$maximum; if ($maximum <= 0) { return false; } return wayfarerRuntimeStoryletOccurrenceCount( $runtimeState, (int)($storylet['id'] ?? 0) ) >= $maximum; } function wayfarerRuntimeStoryletConditionsPass( array $storylet, array &$runtimeState ): bool { $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); $conditions = $config['conditions'] ?? null; $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $regionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $regionId, $scope, $targetType, $targetId ); }; return wayfarerConditionsPass( $conditions, $resolver ); } function wayfarerRuntimeStoryletIsEligible( array $storylet, array &$runtimeState ): bool { if ( strtolower( trim( (string)( $storylet['status'] ?? '' ) ) ) !== 'active' ) { return false; } if ( wayfarerRuntimeStoryletOccurrenceLimitReached( $storylet, $runtimeState ) ) { return false; } return wayfarerRuntimeStoryletConditionsPass( $storylet, $runtimeState ); } function wayfarerRuntimeStoryletPlayerPayload( array $storylet, array $runtimeState, bool $asBranch = false ): array { $name = trim( (string)( $storylet['name'] ?? '' ) ); $branchLabel = trim( (string)( $storylet['branch_label'] ?? '' ) ); $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); return [ 'id' => (int)( $storylet['id'] ?? 0 ), 'name' => $name, 'label' => $asBranch && $branchLabel !== '' ? $branchLabel : $name, 'description' => trim( (string)( $storylet['description'] ?? '' ) ), 'priority' => (int)( $config['priority'] ?? 100 ), 'occurrences' => wayfarerRuntimeStoryletOccurrenceCount( $runtimeState, (int)( $storylet['id'] ?? 0 ) ), 'max_occurrences' => $storylet['max_occurrences'] === null || $storylet['max_occurrences'] === '' ? null : (int)$storylet['max_occurrences'], ]; } function wayfarerRuntimeEligibleRootManualStorylets( PDO $pdo, int $gameId, array &$runtimeState ): array { if ($gameId <= 0) { return []; } $stmt = $pdo->prepare( " SELECT id, game_id, name, description, parent_storylet_id, branch_label, branch_sort_order, trigger_type, max_occurrences, status, config FROM wf_storylets WHERE game_id = ? AND status = 'active' AND trigger_type = 'manual' AND ( parent_storylet_id IS NULL OR parent_storylet_id = 0 ) ORDER BY id " ); $stmt->execute([ $gameId ]); $actions = []; foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $storylet ) { $storylet['_runtime_config'] = wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $runtimeState ) ) { continue; } $actions[] = wayfarerRuntimeStoryletPlayerPayload( $storylet, $runtimeState, false ); } usort( $actions, function ( array $a, array $b ): int { $priority = ((int)($a['priority'] ?? 100)) <=> ((int)($b['priority'] ?? 100)); if ($priority !== 0) { return $priority; } $label = strcasecmp( (string)($a['label'] ?? ''), (string)($b['label'] ?? '') ); if ($label !== 0) { return $label; } return ((int)($a['id'] ?? 0)) <=> ((int)($b['id'] ?? 0)); } ); return $actions; } function wayfarerRuntimeEligibleStoryletBranches( PDO $pdo, int $gameId, int $parentStoryletId, array &$runtimeState ): array { if ( $gameId <= 0 || $parentStoryletId <= 0 ) { return []; } $stmt = $pdo->prepare( " SELECT id, game_id, name, description, parent_storylet_id, branch_label, branch_sort_order, trigger_type, max_occurrences, status, config FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? AND status = 'active' ORDER BY branch_sort_order, name, id " ); $stmt->execute([ $gameId, $parentStoryletId, ]); $branches = []; foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $storylet ) { $storylet['_runtime_config'] = wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $runtimeState ) ) { continue; } $payload = wayfarerRuntimeStoryletPlayerPayload( $storylet, $runtimeState, true ); $payload['branch_sort_order'] = (int)( $storylet['branch_sort_order'] ?? 0 ); $branches[] = $payload; } return $branches; } function wayfarerRuntimeEnsureStoryletTargetState( PDO $pdo, int $gameId, array &$runtimeState ): void { wayfarerRuntimeEnsureAllRegionTraitState( $pdo, $gameId, $runtimeState ); wayfarerRuntimeEnsureAllLocationTraitState( $pdo, $gameId, $runtimeState ); wayfarerRuntimeEnsureEntityState( $pdo, $gameId, $runtimeState ); wayfarerRuntimeEnsurePlayerAndGameState( $pdo, $gameId, $runtimeState ); } function wayfarerRuntimeStoryletTraitDefinitions( PDO $pdo, int $gameId, array $runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionConfig = []; if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $raw = $stmt->fetchColumn(); if ($raw !== false) { $regionConfig = wayfarerRuntimeDecodeTraitConfig( $raw ); } } return wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); } function wayfarerRuntimeApplyStoryletEffects( PDO $pdo, int $gameId, array $storylet, array &$runtimeState ): array { $config = isset($storylet['_runtime_config']) && is_array($storylet['_runtime_config']) ? $storylet['_runtime_config'] : wayfarerRuntimeDecodeConfig( $storylet['config'] ?? null ); $effects = $config['effects'] ?? []; if (!is_array($effects)) { $effects = []; } $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $traitDefinitions = wayfarerRuntimeStoryletTraitDefinitions( $pdo, $gameId, $runtimeState ); wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $regionId ); $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { throw new RuntimeException( 'Malformed Storylet Effect.' ); } $report = wayfarerApplyRuntimeOneTimeEffect( $runtimeState, $effect, $regionId, $traitDefinitions ); if (!empty($report['error'])) { throw new RuntimeException( 'Storylet Effect failed: ' . (string)$report['error'] ); } $reports[] = $report; } return $reports; } function wayfarerRuntimeIncrementStoryletOccurrence( array &$runtimeState, int $storyletId ): int { if ( !isset($runtimeState['storylets']) || !is_array($runtimeState['storylets']) ) { $runtimeState['storylets'] = []; } $key = (string)$storyletId; if ( !isset($runtimeState['storylets'][$key]) || !is_array($runtimeState['storylets'][$key]) ) { $runtimeState['storylets'][$key] = []; } $count = wayfarerRuntimeStoryletOccurrenceCount( $runtimeState, $storyletId ) + 1; $runtimeState['storylets'][$key]['occurrences'] = $count; $runtimeState['storylets'][$key]['last_entered_tick'] = (int)( $runtimeState['runtime']['tick'] ?? 0 ); return $count; } function wayfarerRuntimeEnterStorylet( PDO $pdo, int $gameUserId, int $storyletId, string $entryMode = 'action', int $expectedParentStoryletId = 0 ): array { $entryMode = strtolower( trim($entryMode) ); if ( $entryMode !== 'action' && $entryMode !== 'branch' ) { throw new RuntimeException( 'Invalid Storylet entry mode.' ); } try { $pdo->beginTransaction(); $stmt = $pdo->prepare( " SELECT id, game_id, state FROM wf_game_users WHERE id = ? LIMIT 1 FOR UPDATE " ); $stmt->execute([ $gameUserId ]); $row = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$row) { throw new RuntimeException( 'Game-user runtime record not found.' ); } $gameId = (int)$row['game_id']; $state = wayfarerRuntimeDecodeState( $row['state'] ); if ( !isset($state['runtime']) || !is_array($state['runtime']) ) { $state['runtime'] = []; } if ( isset($state['runtime']['ending']) && is_array($state['runtime']['ending']) ) { throw new RuntimeException( 'This game has ended.' ); } $activePresentation = $state['runtime']['presentation'] ?? null; $storylet = wayfarerRuntimeLoadStorylet( $pdo, $gameId, $storyletId ); if (!$storylet) { throw new RuntimeException( 'Storylet not found.' ); } if ($entryMode === 'action') { if (is_array($activePresentation)) { throw new RuntimeException( 'Continue the current presentation before choosing an Action.' ); } if ( !empty($storylet['parent_storylet_id']) || strtolower( trim( (string)( $storylet['trigger_type'] ?? '' ) ) ) !== 'manual' ) { throw new RuntimeException( 'That Storylet is not an available root Manual Action.' ); } } else { if ( !is_array($activePresentation) || (string)( $activePresentation['type'] ?? '' ) !== 'storylet' || (int)( $activePresentation['id'] ?? 0 ) <= 0 ) { throw new RuntimeException( 'There is no active Storylet Branch to choose from.' ); } $parentStoryletId = (int)( $activePresentation['id'] ?? 0 ); if ( $expectedParentStoryletId > 0 && $parentStoryletId !== $expectedParentStoryletId ) { throw new RuntimeException( 'The active Storylet changed before the Branch was selected.' ); } if ( (int)( $storylet['parent_storylet_id'] ?? 0 ) !== $parentStoryletId ) { throw new RuntimeException( 'That Storylet is not a Branch of the active Storylet.' ); } } wayfarerRuntimeEnsureStoryletTargetState( $pdo, $gameId, $state ); if ( !wayfarerRuntimeStoryletIsEligible( $storylet, $state ) ) { throw new RuntimeException( 'That Storylet is no longer eligible.' ); } $config = $storylet['_runtime_config'] ?? []; $tickCost = max( 0, (int)( $config['tick_cost'] ?? 0 ) ); /* * V59 proves interactive Storylets before refactoring the normal tick * pipeline into a reusable "consume N ticks" helper. Reject non-zero * cost rather than silently ignoring authored time passage. */ if ($tickCost > 0) { throw new RuntimeException( 'Storylet Tick Cost runtime is not implemented yet. Set Tick Cost to 0 for V59.' ); } $effects = wayfarerRuntimeApplyStoryletEffects( $pdo, $gameId, $storylet, $state ); $occurrence = wayfarerRuntimeIncrementStoryletOccurrence( $state, $storyletId ); if ($entryMode === 'branch') { $state['runtime']['last_presentation'] = $activePresentation; } $state['runtime']['presentation'] = [ 'type' => 'storylet', 'id' => $storyletId, 'phase' => 'presentation', 'trigger' => $entryMode, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; $state['runtime']['last_storylet_entry'] = [ 'storylet_id' => $storyletId, 'parent_storylet_id' => (int)( $storylet['parent_storylet_id'] ?? 0 ), 'entry_mode' => $entryMode, 'occurrence' => $occurrence, 'tick' => (int)( $state['runtime']['tick'] ?? 0 ), ]; $ending = wayfarerRuntimeEvaluateGameEndings( $pdo, $gameId, $state ); $encoded = wayfarerRuntimeEncodeState( $state ); $update = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $update->execute([ $encoded, $gameUserId, ]); $pdo->commit(); $presentation = $state['runtime']['presentation'] ?? null; return [ 'game_user_id' => (int)$row['id'], 'game_id' => $gameId, 'state' => $state, 'context' => wayfarerRuntimePlayerContext( $pdo, $gameId, $state ), 'presentation' => is_array($presentation) ? $presentation : null, 'ending' => $ending, 'effects' => $effects, 'occurrence' => $occurrence, 'actions' => $ending === null && !is_array($presentation) ? wayfarerRuntimeEligibleRootManualStorylets( $pdo, $gameId, $state ) : [], ]; } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } } ================================================================================ FILE: update-backups/v70-20260926-031619/play/index.php ================================================================================ prepare(" SELECT g.id AS game_id, g.name AS game_name, g.slug AS game_slug, g.instructions AS game_instructions, g.player_slots AS player_slots, g.config AS game_config, gu.id AS game_user_id, gu.role AS game_user_role, gu.state, u.username, u.display_name FROM wf_game_users gu INNER JOIN wf_games g ON g.id = gu.game_id INNER JOIN wf_users u ON u.id = gu.user_id WHERE gu.game_id = ? AND gu.user_id = ? LIMIT 1 "); $stmt->execute([ $gameId, $userId ]); $gameUser = $stmt->fetch(); if (!$gameUser) { die('Game user not found.'); } /* * WAYFARER_ROLE_BASED_AUTHOR_MODE_V37 * * Author permission belongs to the user/game relationship, not to the user * globally. wf_game_users.role is therefore the canonical source. */ $canUseAuthorMode = strtolower( trim( (string)( $gameUser['game_user_role'] ?? '' ) ) ) === 'author'; // ================================================================ // RUNTIME STATE // ================================================================ $state = null; if (!empty($gameUser['state'])) { $decodedState = json_decode( $gameUser['state'], true ); if (is_array($decodedState)) { $state = $decodedState; } } /* * WAYFARER_ENTITY_RUNTIME_STATE_PAGE_RECOVERY_V49 * * Existing development playthroughs should gain Entity state immediately on * page load. This is additive and does not overwrite current runtime values. */ if ($state !== null) { $runtimeRecoveryChanged = wayfarerRuntimeEnsureEntityState( $pdo, (int)$gameId, $state ); /* WAYFARER_PLAYER_TRAITS_PAGE_RECOVERY_V52 */ if ( wayfarerRuntimeEnsurePlayerAndGameState( $pdo, (int)$gameId, $state ) ) { $runtimeRecoveryChanged = true; } if ($runtimeRecoveryChanged) { $encodedRecoveredState = wayfarerRuntimeEncodeState( $state ); $entityRecoveryStmt = $pdo->prepare( " UPDATE wf_game_users SET state = ? WHERE id = ? " ); $entityRecoveryStmt->execute([ $encodedRecoveredState, (int)$gameUser['game_user_id'], ]); /* * Keep Author Debug and any later page component on the same snapshot. */ $gameUser['state'] = $encodedRecoveredState; } } // ================================================================ // INITIALIZE GAME // ================================================================ $initializationError = ''; if ( $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'initialize' && $state === null ) { try { wayfarerRuntimeInitializeGame( $pdo, (int)$gameUser['game_user_id'], [ 'player_names' => $_POST['player_names'] ?? [], 'choices' => $_POST['setup_choices'] ?? [], ] ); /* * Reload from persisted state so every play-page component sees the * exact same initialized snapshot. */ header('Location: index.php'); exit; } catch (Throwable $exception) { $initializationError = $exception->getMessage(); } } // ================================================================ // AUTHOR MODE: GAME TRAIT DEFINITIONS // ================================================================ $gameTraitDefinitions = []; $gameConfig = []; if (!empty($gameUser['game_config'])) { $decodedConfig = json_decode( $gameUser['game_config'], true ); if (is_array($decodedConfig)) { $gameConfig = $decodedConfig; } } foreach ($gameConfig['traits'] ?? [] as $trait) { if (empty($trait['id'])) { continue; } $gameTraitDefinitions[$trait['id']] = $trait; } ?> <?= htmlspecialchars($gameUser['game_name']) ?>

Player:



================================================================================ FILE: update-v104-exchanges.php ================================================================================ prepare( " SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'wf_exchanges' " ); $tableExistsStmt->execute(); $tableExists = (int)$tableExistsStmt->fetchColumn() > 0; if (!$tableExists) { $pdo->exec( " CREATE TABLE `wf_exchanges` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `game_id` INT UNSIGNED NOT NULL, `name` VARCHAR(200) NOT NULL, `description` TEXT NULL, `max_occurrences` INT UNSIGNED NULL, `status` VARCHAR(20) NOT NULL DEFAULT 'draft', `config` LONGTEXT NULL, PRIMARY KEY (`id`), KEY `idx_wf_exchanges_game` (`game_id`), KEY `idx_wf_exchanges_game_status` (`game_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci " ); echo "Created wf_exchanges.\n"; } else { echo "wf_exchanges already exists; no table creation needed.\n"; } $requiredColumns = [ 'id', 'game_id', 'name', 'description', 'max_occurrences', 'status', 'config', ]; $columnStmt = $pdo->prepare( " SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'wf_exchanges' " ); $columnStmt->execute(); $existingColumns = array_map( 'strtolower', $columnStmt->fetchAll(PDO::FETCH_COLUMN) ); $missingColumns = array_values( array_diff( $requiredColumns, $existingColumns ) ); if ($missingColumns) { throw new RuntimeException( 'wf_exchanges exists but is missing required columns: ' . implode(', ', $missingColumns) ); } echo "V104 Exchange schema verified.\n"; echo "Update V104 complete.\n"; } catch (Throwable $exception) { http_response_code(500); echo "Update V104 failed.\n\n"; echo $exception->getMessage() . "\n"; } ================================================================================ FILE: updates/backups/v80-hierarchical-targets-20260929-140949/admin/builder.php ================================================================================ prepare(" SELECT id, name, config FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; /* WAYFARER_STORYLET_BUILDER_V56 * A + New Branch Storylet link can preselect its parent and choose a sensible * sibling order without making parent_storylet_id part of the generic request. */ if ( $type === 'storylets' && !$id && !empty($_GET['parent_storylet_id']) ) { $requestedParentId = (int)$_GET['parent_storylet_id']; $parentStmt = $pdo->prepare( " SELECT id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $parentStmt->execute([ $requestedParentId, $gameId, ]); if ($parentStmt->fetchColumn()) { $record['parent_storylet_id'] = $requestedParentId; $orderStmt = $pdo->prepare( " SELECT COALESCE(MAX(branch_sort_order), 0) FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? " ); $orderStmt->execute([ $gameId, $requestedParentId, ]); $record['branch_sort_order'] = (int)$orderStmt->fetchColumn() + 10; } } // ================================================================ // SAVE / DELETE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { /* * Media Library associations are applied only after the authored * object has saved successfully and therefore has a stable ID. */ $savedRecordId = (int)$saveResult['id']; $mediaResult = applyBuilderMediaSelections( $pdo, $gameId, $definition['table'], $savedRecordId, $_POST, $_FILES ); if (!$mediaResult['saved']) { /* * The authored object itself has already saved. Keep the * editor on that record and report the media failure * accurately rather than pretending the object save failed. */ $id = $savedRecordId; $record['id'] = $savedRecordId; $error = 'Record saved, but media was not changed: ' . $mediaResult['error']; } else { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) . '&id=' . (int)$saveResult['id'] . '&tab=' . rawurlencode( (string)($_POST['active_tab'] ?? 'basics') ) ); exit; } } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder

================================================================================ FILE: updates/backups/v80-hierarchical-targets-20260929-140949/admin/games/edit.php ================================================================================ null, 'name' => '', 'slug' => '', 'description' => '', 'instructions' => '', 'player_slots' => 0, 'config' => null, 'status' => 'draft' ]; $traits = []; $playerTraits = []; $config = []; /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ $gameTickEffects = []; $gameplayTickEffects = []; $startingChoices = []; $startingChoicesEditor = []; $startingChoiceItemTargets = []; /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 */ $gameEndings = []; $gameEndingsEditor = []; /* WAYFARER_GAME_TRAVEL_MODIFIERS_V73 */ $gameTravelModifiers = []; $gameTravelModifiersEditor = []; $error = ''; // ================================================================ // LOAD EXISTING GAME // ================================================================ if (isset($_GET['id'])) { $stmt = $pdo->prepare(" SELECT * FROM wf_games WHERE id = ? "); $stmt->execute([(int)$_GET['id']]); $existingGame = $stmt->fetch(); if (!$existingGame) { die('Game not found.'); } $game = array_merge($game, $existingGame); if (!empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } if ( isset($config['traits']) && is_array($config['traits']) ) { $traits = $config['traits']; } /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ if ( isset($config['player_traits']) && is_array($config['player_traits']) ) { $playerTraits = array_values( $config['player_traits'] ); } /* WAYFARER_LOAD_GAME_TICK_EFFECTS_V33 */ if ( isset($config['tick_effects']) && is_array($config['tick_effects']) ) { $gameTickEffects = array_values( $config['tick_effects'] ); } if ( isset($config['setup']['choices']) && is_array($config['setup']['choices']) ) { $startingChoices = array_values( $config['setup']['choices'] ); } /* WAYFARER_GAME_ENDINGS_V45 */ if ( isset($config['endings']) && is_array($config['endings']) ) { $gameEndings = array_values( $config['endings'] ); } /* WAYFARER_LOAD_GAME_TRAVEL_MODIFIERS_V73 */ if ( isset($config['travel']['modifiers']) && is_array($config['travel']['modifiers']) ) { $gameTravelModifiers = array_values( $config['travel']['modifiers'] ); } } $gameplayTickEffects = $gameTickEffects; $startingChoicesEditor = $startingChoices; $gameEndingsEditor = $gameEndings; $gameTravelModifiersEditor = $gameTravelModifiers; // ================================================================ // SAVE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $game['id'] = !empty($_POST['id']) ? (int)$_POST['id'] : null; $game['name'] = trim($_POST['name'] ?? ''); $game['slug'] = trim($_POST['slug'] ?? ''); $game['description'] = trim($_POST['description'] ?? ''); $game['instructions'] = trim($_POST['instructions'] ?? ''); $game['player_slots'] = max(0, (int)($_POST['player_slots'] ?? 0)); $game['status'] = $_POST['status'] ?? 'draft'; // ------------------------------------------------------------ // BUILD GAME + PLAYER TRAIT DEFINITIONS // WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 // ------------------------------------------------------------ $traitResult = buildTraitsFromPost( $_POST['traits'] ?? [], ['status'] ); $traits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } $playerTraitResult = buildTraitsFromPost( $_POST['player_traits'] ?? [], ['status'] ); $playerTraits = $playerTraitResult['traits']; if ( $error === '' && $playerTraitResult['error'] !== '' ) { $error = 'Player Traits: ' . $playerTraitResult['error']; } /* WAYFARER_GAME_SETTINGS_UNIVERSAL_TARGETS_V56 */ $gameplayRuntimeTargets = gameplayRuntimeStateTargets( $pdo, (int)($game['id'] ?? 0), $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ) ); /* WAYFARER_BUILD_GAME_TRAVEL_MODIFIERS_V73 */ $postedGameTravelModifiers = isset($_POST['travel_modifiers']) && is_array($_POST['travel_modifiers']) ? array_values( $_POST['travel_modifiers'] ) : []; $gameTravelModifiersEditor = $postedGameTravelModifiers; /* * Build the editor structure even if another Game Settings section already * has a validation error. This keeps selected Travel Conditions intact on the * failed POST. Only the first validation error becomes the page error. */ $gameTravelModifierResult = wayfarerBuildGameTravelModifiersFromPost( $postedGameTravelModifiers, $gameplayRuntimeTargets ); $gameTravelModifiersEditor = $gameTravelModifierResult[ 'editor_modifiers' ]; if ( $error === '' && $gameTravelModifierResult['error'] !== '' ) { $error = $gameTravelModifierResult['error']; } if ( $gameTravelModifierResult['error'] === '' ) { $gameTravelModifiers = $gameTravelModifierResult[ 'modifiers' ]; } /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 */ $startingChoiceItemTargets = wayfarerStartingChoiceItemQuantityTargets( $pdo, (int)($game['id'] ?? 0) ); /* WAYFARER_BUILD_STARTING_CHOICES_V36 */ /* WAYFARER_STARTING_CHOICES_CANONICAL_EMPTY_V36_1 */ $startingChoicesSubmitted = isset($_POST['setup_choices_present']) || array_key_exists( 'setup_choices', $_POST ); $postedStartingChoices = isset($_POST['setup_choices']) && is_array($_POST['setup_choices']) ? array_values( $_POST['setup_choices'] ) : []; $startingChoicesEditor = $postedStartingChoices; if ($error === '') { $startingChoiceResult = wayfarerBuildStartingChoicesFromPost( $postedStartingChoices, $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ), $startingChoiceItemTargets, $gameplayRuntimeTargets ); if ( $startingChoiceResult['error'] !== '' ) { $error = $startingChoiceResult['error']; } else { $startingChoices = $startingChoiceResult['choices']; $startingChoicesEditor = $startingChoices; } } /* WAYFARER_BUILD_GAME_ENDINGS_V45 */ $postedGameEndings = isset($_POST['endings']) && is_array($_POST['endings']) ? array_values( $_POST['endings'] ) : []; $gameEndingsEditor = $postedGameEndings; if ($error === '') { $endingResult = wayfarerBuildGameEndingsFromPost( $pdo, (int)($game['id'] ?? 0), $postedGameEndings, $traits ); if ( $endingResult['error'] !== '' ) { $error = $endingResult['error']; } else { $gameEndings = $endingResult['endings']; $gameEndingsEditor = $gameEndings; } } /* WAYFARER_BUILD_GAME_TICK_EFFECTS_V33 */ $postedGameTickEffects = isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ? array_values( $_POST['tick_effects'] ) : []; $gameplayTickEffects = $postedGameTickEffects; if ($error === '') { $gameTickEffectResult = gameplayBuildTickEffectsFromPost( $gameplayRuntimeTargets, $postedGameTickEffects, $gameplayRuntimeTargets ); if ( $gameTickEffectResult['error'] !== '' ) { $error = $gameTickEffectResult['error']; } else { $gameTickEffects = $gameTickEffectResult['effects']; $gameplayTickEffects = $gameTickEffects; } } // ------------------------------------------------------------ // VALIDATE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['name'] === '') { $error = 'Game name is required.'; } elseif ($game['slug'] === '') { $error = 'Slug is required.'; } } // ------------------------------------------------------------ // BUILD CONFIG JSON // ------------------------------------------------------------ if ($error === '') { /* * Preserve any other configuration already stored for the game. * Traits own only the "traits" section of config. */ if (empty($config) && !empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } $startingRegionId = !empty($_POST['starting_region_id']) ? (int)$_POST['starting_region_id'] : null; if ($startingRegionId !== null) { $config['starting_region_id'] = $startingRegionId; } else { unset($config['starting_region_id']); } /* WAYFARER_GAME_TIME_SETTINGS_SAVE_V61 */ $timeMode = strtolower(trim((string)($_POST['time_tracking'] ?? 'none'))); if (!in_array($timeMode, ['none', 'days', 'hours'], true)) { $timeMode = 'none'; } if ($timeMode === 'none') { unset($config['time']); } else { $startingDay = max(1, (int)($_POST['time_starting_day'] ?? 1)); if ($timeMode === 'days') { $config['time'] = [ 'mode' => 'days', 'starting_day' => $startingDay, 'ticks_per_day' => max(1, (int)($_POST['time_ticks_per_day'] ?? 1)), ]; } else { $hoursPerDay = max(1, (int)($_POST['time_hours_per_day'] ?? 24)); $startingHour = max(0, (int)($_POST['time_starting_hour'] ?? 8)); if ($startingHour >= $hoursPerDay) { $startingHour = $hoursPerDay - 1; } $config['time'] = [ 'mode' => 'hours', 'starting_day' => $startingDay, 'starting_hour' => $startingHour, 'ticks_per_hour' => max(1, (int)($_POST['time_ticks_per_hour'] ?? 1)), 'hours_per_day' => $hoursPerDay, ]; } } $config['traits'] = array_values( $traits ); /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ $config['player_traits'] = array_values( $playerTraits ); /* WAYFARER_SAVE_GAME_TRAVEL_MODIFIERS_V73 */ if ($gameTravelModifiers) { if ( !isset($config['travel']) || !is_array($config['travel']) ) { $config['travel'] = []; } $config['travel']['modifiers'] = array_values( $gameTravelModifiers ); } else { if ( isset($config['travel']) && is_array($config['travel']) ) { unset( $config['travel']['modifiers'] ); if ($config['travel'] === []) { unset($config['travel']); } } } /* WAYFARER_SAVE_GAME_TICK_EFFECTS_V33 */ if ($gameTickEffects) { $config['tick_effects'] = array_values( $gameTickEffects ); } else { unset( $config['tick_effects'] ); } /* WAYFARER_SAVE_STARTING_CHOICES_V36 */ /* * WAYFARER_STARTING_CHOICES_CANONICAL_EMPTY_V36_1 * * If the Setup editor participated in this submission, its current * collection is the canonical collection. In particular, deleting the * final card must persist an explicit empty array rather than relying * on the absence of setup_choices in POST. */ if ($startingChoicesSubmitted) { if ( !isset($config['setup']) || !is_array($config['setup']) ) { $config['setup'] = []; } $config['setup']['choices'] = array_values( $startingChoices ); } /* WAYFARER_SAVE_GAME_ENDINGS_V45 */ $config['endings'] = array_values( $gameEndings ); $game['config'] = json_encode( $config, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ($game['config'] === false) { $error = 'Unable to encode game configuration.'; } } // ------------------------------------------------------------ // WRITE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['id']) { $stmt = $pdo->prepare(" UPDATE wf_games SET name = ?, slug = ?, description = ?, instructions = ?, player_slots = ?, config = ?, status = ? WHERE id = ? "); $stmt->execute([ $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'], $game['id'] ]); } else { $uuid = bin2hex(random_bytes(16)); $stmt = $pdo->prepare(" INSERT INTO wf_games ( uuid, name, slug, description, instructions, player_slots, config, status ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $uuid, $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'] ]); $game['id'] = (int)$pdo->lastInsertId(); } /* * WAYFARER_STAY_ON_GAME_SETTINGS_V38 * * Successful saves remain on the same Game Settings record and reopen * the tab the author was using. */ $savedGameTab = (string)( $_POST['active_tab'] ?? 'basics' ); if ( !in_array( $savedGameTab, [ 'basics', 'setup', 'traits', 'gameplay', 'travel', 'endings', ], true ) ) { $savedGameTab = 'basics'; } header( 'Location: edit.php?id=' . (int)$game['id'] . '&tab=' . rawurlencode( $savedGameTab ) ); exit; } } // ================================================================ // LOAD REGIONS FOR STARTING REGION // ================================================================ $regions = []; if (!empty($game['id'])) { $regionStmt = $pdo->prepare(" SELECT id, name FROM wf_regions WHERE game_id = ? ORDER BY name "); $regionStmt->execute([ $game['id'] ]); $regions = $regionStmt->fetchAll(); } /* WAYFARER_GAME_SETTINGS_UNIVERSAL_TARGETS_RENDER_V56 */ $gameplayRuntimeTargets = gameplayRuntimeStateTargets( $pdo, (int)($game['id'] ?? 0), $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ) ); /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_RENDER_V54 */ $startingChoiceItemTargets = wayfarerStartingChoiceItemQuantityTargets( $pdo, (int)($game['id'] ?? 0) ); ?> <?= $game['id'] ? 'Edit Game' : 'New Game' ?> - Wayfarer


Example: stormgate

================================================================================ FILE: updates/backups/v80-hierarchical-targets-20260929-140949/includes/gameplay/condition-editor.php ================================================================================ Select target...'; $grouped = []; foreach ($targets as $key => $target) { if (!is_array($target)) { continue; } /* * WAYFARER_LOCATION_CONDITION_TARGETS_V75 * * Keep a hidden legacy target visible only when an existing * Condition already uses it. */ if ( !empty($target['condition_hidden']) && $key !== $selected ) { continue; } $label = (string)( $target['label'] ?? $key ); $group = (string)( $target['group'] ?? 'Targets' ); if (!isset($grouped[$group])) { $grouped[$group] = []; } $helper = trim( (string)( $target['helper'] ?? '' ) ); $grouped[$group][$key] = $helper !== '' ? $label . ' — ' . $helper : $label; } foreach ($grouped as $group => $options) { echo ''; foreach ($options as $value => $label) { echo ''; } echo ''; } } function wayfarerConditionEditorRenderNode( $node, string $name, array $targets, bool $isRoot = false ): void { if ( !is_array( $node ) || ( ($node['type'] ?? '') !== 'group' && ($node['type'] ?? '') !== 'condition' ) ) { $node = wayfarerConditionEmptyGroup( 'all' ); } $type = (string)( $node['type'] ?? 'group' ); if ($type === 'condition') { $targetKey = (string)( $node['scope'] ?? '' ) . ':' . (string)( $node['target_type'] ?? '' ) . ':' . (string)( $node['target_id'] ?? '' ); $operator = (string)( $node['operator'] ?? 'equals' ); $value = $node['value'] ?? ''; /* WAYFARER_DATATYPE_CONDITION_EDITOR_V58 */ $targetDefinition = isset($targets[$targetKey]) && is_array($targets[$targetKey]) ? $targets[$targetKey] : []; $allowedOperators = isset($targetDefinition['condition_operators']) && is_array($targetDefinition['condition_operators']) ? array_values($targetDefinition['condition_operators']) : wayfarerConditionOperators(); $valueOptions = isset($targetDefinition['value_options']) && is_array($targetDefinition['value_options']) ? $targetDefinition['value_options'] : []; $dataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($dataType === 'binary') { $dataType = 'boolean'; } ?>
of the following
$child ): ?> '; wayfarerConditionEditorRenderNode( $child, $name . '[children][' . $childIndex . ']', $targets, false ); echo '
'; ?>
'group', 'mode' => 'all', 'children' => [ $conditionEditorNode, ], ]; } ?>
================================================================================ FILE: updates/backups/v80-hierarchical-targets-20260929-140949/includes/gameplay/effects-editor.php ================================================================================ operation/value * OTHERWISE -> operation/value * * First matching result wins. Each result may use a Fixed value or roll a * Random Min-Max value once when the Effect executes. */ $gameplayEffects = isset( $gameplayEffects ) && is_array( $gameplayEffects ) ? array_values( $gameplayEffects ) : []; $gameplayGameTraitTargets = $gameplayGameTraitTargets ?? []; $gameplayScopeTraitTargets = $gameplayScopeTraitTargets ?? []; $gameplayScopeKey = (string)( $gameplayScopeKey ?? '' ); $gameplayScopeLabel = (string)( $gameplayScopeLabel ?? '' ); $gameplayAllowEffectNavigation = !empty( $gameplayAllowEffectNavigation ); $gameplayEffectDescription = (string)( $gameplayEffectDescription ?? 'These Effects run once when this object is triggered.' ); $gameplayConditionTargets = isset( $gameplayConditionTargets ) && is_array( $gameplayConditionTargets ) ? $gameplayConditionTargets : []; /* WAYFARER_LIVE_LOCATION_ANCHORS_V76 */ if ( isset($pdo, $gameId) && $pdo instanceof PDO && function_exists( 'gameplayNavigationConditionTargets' ) ) { $gameplayConditionTargets = array_replace( $gameplayConditionTargets, gameplayNavigationConditionTargets( $pdo, (int)$gameId ) ); } /* WAYFARER_UNIVERSAL_EFFECT_TARGET_EDITOR_V56 */ $gameplayTargetCatalog = isset($gameplayTargetCatalog) && is_array($gameplayTargetCatalog) ? $gameplayTargetCatalog : []; $renderEffectTargetOptions = function ( array $effect = [] ) use ( $gameplayAllowEffectNavigation, $gameplayGameTraitTargets, $gameplayScopeKey, $gameplayScopeLabel, $gameplayScopeTraitTargets, $gameplayTargetCatalog ): void { echo ''; if ($gameplayTargetCatalog) { $selectedKey = (string)( $effect['target_scope'] ?? '' ) . ':' . (string)( $effect['target_type'] ?? '' ) . ':' . (string)( $effect['target_id'] ?? '' ); $grouped = []; foreach ( $gameplayTargetCatalog as $value => $target ) { if (!is_array($target)) { continue; } $group = (string)( $target['group'] ?? 'Targets' ); if (!isset($grouped[$group])) { $grouped[$group] = []; } $grouped[$group][$value] = $target; } foreach ($grouped as $group => $options) { echo ''; foreach ($options as $value => $target) { echo ''; } echo ''; } return; } if ($gameplayAllowEffectNavigation) { echo ''; echo ''; echo ''; } if ($gameplayGameTraitTargets) { echo ''; foreach ( $gameplayGameTraitTargets as $targetTrait ) { echo ''; } echo ''; } if ( $gameplayScopeKey !== '' && $gameplayScopeTraitTargets ) { echo ''; foreach ( $gameplayScopeTraitTargets as $targetTrait ) { echo ''; } echo ''; } }; $renderEffectResultFields = function ( array $branch, string $name ): void { $operation = gameplayEffectBranchOperation( $branch ); $valueMode = gameplayEffectBranchValueMode( $branch ); $value = gameplayEffectBranchValue( $branch ); $minimum = gameplayEffectBranchMin( $branch ); $maximum = gameplayEffectBranchMax( $branch ); $noChange = $operation === 'none'; ?>
>

Effects

Each Effect controls one target. Results are checked from top to bottom; the first matching result wins. Random values are rolled once when the Effect fires.

$effect ): ?>
$branch ): ?>
THEN

================================================================================ FILE: updates/backups/v80-hierarchical-targets-20260929-140949/includes/gameplay/functions.php ================================================================================ $scope, 'id' => $traitId, 'name' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayTickEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { $allowedTargets = []; if ($allowLinearNavigation) { $allowedTargets[ 'navigation:position' ] = [ 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', ]; } foreach ( gameplayNumericTraitTargets( $gameTraits, 'game' ) as $target ) { $key = 'game:' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => 'game', 'target_type' => 'trait', 'target_id' => $target['id'], ]; } if ($scope !== '') { foreach ( gameplayNumericTraitTargets( $scopeTraits, $scope ) as $target ) { $key = $scope . ':' . $target['id']; $allowedTargets[$key] = [ 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $target['id'], ]; } } return $allowedTargets; } /* |-------------------------------------------------------------------------- | Tick Effect result branches |-------------------------------------------------------------------------- | | WAYFARER_TICK_EFFECT_BRANCHES_V28 | | Canonical shape: | | { | "target_scope": "game", | "target_type": "trait", | "target_id": "water", | "branches": [ | { | "conditions": { ... }, | "operation": "subtract", | "value": 2 | }, | { | "conditions": null, | "operation": "subtract", | "value": 1 | } | ] | } | | A branch with conditions === null is OTHERWISE. | Branches are ordered. Runtime will later use first-match-wins semantics. | */ function gameplayTickEffectNormalizeBranches( array $effect ): array { if ( isset( $effect['branches'] ) && is_array( $effect['branches'] ) ) { $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = (string)( $branch['operation'] ?? 'add' ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * Legacy v27 single-result effect becomes one branch. */ if ( array_key_exists('operation', $effect) || array_key_exists('value', $effect) || array_key_exists('value_mode', $effect) || array_key_exists('value_min', $effect) || array_key_exists('value_max', $effect) || array_key_exists('conditions', $effect) ) { $operation = (string)( $effect['operation'] ?? 'add' ); $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayTickEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayTickEffectBranchOperation( array $branch ): string { return (string)( $branch['operation'] ?? 'add' ); } function gameplayTickEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayTickEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayTickEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayTickEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } /* |-------------------------------------------------------------------------- | One-time Effects | WAYFARER_ONE_TIME_EFFECTS_V42 | WAYFARER_GENERALIZED_EFFECTS_V43 |-------------------------------------------------------------------------- | | Effects use ordered first-match-wins result branches just like Tick Effects, | but execute only when their containing object/event triggers. | | A result may use: | - fixed value | - random inclusive Min-Max value | */ function gameplayEffectAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', bool $allowLinearNavigation = false ): array { return gameplayTickEffectAllowedTargets( $gameTraits, $scopeTraits, $scope, $allowLinearNavigation ); } function gameplayEffectNormalizeBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (!is_array($branch)) { continue; } $operation = strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); $normalized = [ 'conditions' => $branch['conditions'] ?? null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ); if ( $valueMode !== 'random' ) { $valueMode = 'fixed'; } $normalized['value_mode'] = $valueMode; if ($valueMode === 'random') { $normalized['value_min'] = $branch['value_min'] ?? ''; $normalized['value_max'] = $branch['value_max'] ?? ''; } else { $normalized['value'] = $branch['value'] ?? ''; } } $branches[] = $normalized; } return $branches; } /* * v42 legacy one-result Effect becomes a single OTHERWISE result. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $operation = strtolower( trim( (string)( $effect['operation'] ?? 'add' ) ) ); $branch = [ 'conditions' => null, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $effect['value_mode'] ?? 'fixed' ) ) ); if ($valueMode !== 'random') { $valueMode = 'fixed'; } $branch['value_mode'] = $valueMode; if ($valueMode === 'random') { $branch['value_min'] = $effect['value_min'] ?? ''; $branch['value_max'] = $effect['value_max'] ?? ''; } else { $branch['value'] = $effect['value'] ?? ''; } } return [ $branch ]; } return []; } function gameplayEffectBranchIsOtherwise( array $branch ): bool { return !isset( $branch['conditions'] ) || $branch['conditions'] === null || $branch['conditions'] === []; } function gameplayEffectBranchOperation( array $branch ): string { return strtolower( trim( (string)( $branch['operation'] ?? 'add' ) ) ); } function gameplayEffectBranchValueMode( array $branch ): string { return strtolower( trim( (string)( $branch['value_mode'] ?? 'fixed' ) ) ) === 'random' ? 'random' : 'fixed'; } function gameplayEffectBranchValue( array $branch ) { return $branch['value'] ?? ''; } function gameplayEffectBranchMin( array $branch ) { return $branch['value_min'] ?? ''; } function gameplayEffectBranchMax( array $branch ) { return $branch['value_max'] ?? ''; } function gameplayEffectNormalizeNumber( string $value ) { if ( filter_var( $value, FILTER_VALIDATE_INT ) !== false ) { return (int)$value; } return (float)$value; } function gameplayBuildEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( array_values( $postedEffects ) as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); $postedBranches = isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ? array_values( $postedEffect['branches'] ) : []; /* * Completely blank newly-added Effect. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Effect must use a valid target.', ]; } /* WAYFARER_DATATYPE_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = strtolower( trim( (string)( $postedBranch['kind'] ?? '' ) ) ); if ( $kind !== 'conditional' && $kind !== 'otherwise' ) { /* * Existing server-rendered branch structures can be inferred * safely from whether conditions were supplied. */ $kind = isset( $postedBranch['conditions'] ) && $postedBranch['conditions'] !== [] ? 'conditional' : 'otherwise'; } if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'An Effect may have only one OTHERWISE result.', ]; } $otherwiseSeen = true; $conditions = null; } else { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Conditional Effect results must appear before OTHERWISE.', ]; } $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult[ 'error' ], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Effect result must have at least one condition.', ]; } $conditions = $conditionResult[ 'node' ]; } $operation = strtolower( trim( (string)( $postedBranch['operation'] ?? '' ) ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch[ 'value_mode' ] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, [ 'fixed', 'random', ], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch[ 'value_mode' ] = $valueMode; if ( $valueMode === 'random' ) { $minRaw = trim( (string)( $postedBranch[ 'value_min' ] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch[ 'value_max' ] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric( $minRaw ) || !is_numeric( $maxRaw ) ) { return [ 'effects' => [], 'error' => 'Random Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ( (float)$minimum > (float)$maximum ) { return [ 'effects' => [], 'error' => 'Random Effect Min cannot be greater than Max.', ]; } $storedBranch[ 'value_min' ] = $minimum; $storedBranch[ 'value_max' ] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch[ 'value' ] ?? '' ) ); /* WAYFARER_GENERAL_EFFECT_SCALAR_VALUES_V56 */ if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Effect must have at least one result.', ]; } /* * Keep a top-level compatibility projection. v43 runtime reads the * branches directly; this merely keeps the canonical target/effect * record recognizable to older tooling. */ $compatibilityBranch = $branches[0]; foreach ( $branches as $candidateBranch ) { if ( gameplayEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $targetDefinition = $allowedTargets[ $target ]; $storedEffect = [ 'target_scope' => $targetDefinition[ 'target_scope' ], 'target_type' => $targetDefinition[ 'target_type' ], 'target_id' => $targetDefinition[ 'target_id' ], 'operation' => (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ), 'branches' => $branches, ]; $compatibilityOperation = (string)( $compatibilityBranch[ 'operation' ] ?? 'none' ); if ( $compatibilityOperation !== 'none' ) { $compatibilityMode = gameplayEffectBranchValueMode( $compatibilityBranch ); $storedEffect[ 'value_mode' ] = $compatibilityMode; if ( $compatibilityMode === 'random' ) { $storedEffect[ 'value_min' ] = $compatibilityBranch[ 'value_min' ]; $storedEffect[ 'value_max' ] = $compatibilityBranch[ 'value_max' ]; } else { $storedEffect[ 'value' ] = $compatibilityBranch[ 'value' ]; } } /* WAYFARER_EFFECT_TARGET_METADATA_V56 */ if (!empty($targetDefinition['target_system_key'])) { $storedEffect['target_system_key'] = (string)$targetDefinition['target_system_key']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayEffectSelected( array $effect, string $scope, string $targetId ): bool { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } function gameplayBuildTickEffectsFromPost( array $allowedTargets, array $postedEffects, array $allowedConditionTargets = [] ): array { $effects = []; foreach ( $postedEffects as $postedEffect ) { if (!is_array($postedEffect)) { continue; } $target = trim( (string)( $postedEffect['target'] ?? '' ) ); /* * v28 posts branches. Keep a compatibility path for a cached v27 * page that still submits operation/value/conditions at effect level. */ if ( isset( $postedEffect['branches'] ) && is_array( $postedEffect['branches'] ) ) { $postedBranches = array_values( $postedEffect['branches'] ); } elseif ( array_key_exists( 'operation', $postedEffect ) || array_key_exists( 'value', $postedEffect ) || array_key_exists( 'conditions', $postedEffect ) ) { $postedBranches = [ [ 'kind' => !empty( $postedEffect['conditions']['children'] ) ? 'conditional' : 'otherwise', 'conditions' => $postedEffect['conditions'] ?? null, 'operation' => $postedEffect['operation'] ?? '', 'value' => $postedEffect['value'] ?? '', ] ]; } else { $postedBranches = []; } /* * Completely empty newly-added row: ignore it. */ if ( $target === '' && $postedBranches === [] ) { continue; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'effects' => [], 'error' => 'Each Tick Effect must use a valid target.', ]; } if ($postedBranches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* WAYFARER_DATATYPE_TICK_EFFECT_VALIDATION_V58 */ $targetDefinition = $allowedTargets[$target]; $targetDataType = strtolower( trim( (string)( $targetDefinition['data_type'] ?? $targetDefinition['trait_type'] ?? 'text' ) ) ); if ($targetDataType === 'binary') { $targetDataType = 'boolean'; } $targetAllowedOperations = isset($targetDefinition['effect_operations']) && is_array($targetDefinition['effect_operations']) ? array_values($targetDefinition['effect_operations']) : ( $targetDataType === 'number' ? ['set', 'add', 'subtract'] : ['set'] ); $targetSupportsRandom = array_key_exists('supports_random', $targetDefinition) ? !empty($targetDefinition['supports_random']) : $targetDataType === 'number'; $branches = []; $otherwiseSeen = false; foreach ( $postedBranches as $branchIndex => $postedBranch ) { if (!is_array($postedBranch)) { continue; } $kind = trim( (string)( $postedBranch['kind'] ?? 'conditional' ) ); if ( !in_array( $kind, [ 'conditional', 'otherwise', ], true ) ) { return [ 'effects' => [], 'error' => 'Invalid Tick Effect result type.', ]; } if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'Otherwise must be the final result in a Tick Effect.', ]; } $conditions = null; if ($kind === 'otherwise') { if ($otherwiseSeen) { return [ 'effects' => [], 'error' => 'A Tick Effect can have only one Otherwise result.', ]; } $otherwiseSeen = true; } else { $conditionResult = gameplayBuildConditionNodeFromPost( $postedBranch['conditions'] ?? null, $allowedConditionTargets ); if ( $conditionResult['error'] !== '' ) { return [ 'effects' => [], 'error' => $conditionResult['error'], ]; } if ( $conditionResult['node'] === null ) { return [ 'effects' => [], 'error' => 'Each conditional Tick Effect result must have at least one condition.', ]; } $conditions = $conditionResult['node']; } $operation = trim( (string)( $postedBranch['operation'] ?? '' ) ); if ( $operation !== 'none' && !in_array( $operation, $targetAllowedOperations, true ) ) { return [ 'effects' => [], 'error' => 'That Tick Effect operation is not valid for the selected target.', ]; } $storedBranch = [ 'conditions' => $conditions, 'operation' => $operation, ]; if ($operation !== 'none') { $valueMode = strtolower( trim( (string)( $postedBranch['value_mode'] ?? 'fixed' ) ) ); if ( !in_array( $valueMode, ['fixed', 'random'], true ) || ( $valueMode === 'random' && !$targetSupportsRandom ) ) { return [ 'effects' => [], 'error' => $targetSupportsRandom ? 'Tick Effect value type must be Fixed or Random.' : 'Random values are only available for numeric targets.', ]; } $storedBranch['value_mode'] = $valueMode; if ($valueMode === 'random') { $minRaw = trim( (string)( $postedBranch['value_min'] ?? '' ) ); $maxRaw = trim( (string)( $postedBranch['value_max'] ?? '' ) ); if ( $minRaw === '' || $maxRaw === '' || !is_numeric($minRaw) || !is_numeric($maxRaw) ) { return [ 'effects' => [], 'error' => 'Random Tick Effect results require numeric Min and Max values.', ]; } $minimum = gameplayEffectNormalizeNumber( $minRaw ); $maximum = gameplayEffectNormalizeNumber( $maxRaw ); if ((float)$minimum > (float)$maximum) { return [ 'effects' => [], 'error' => 'Random Tick Effect Min cannot be greater than Max.', ]; } $storedBranch['value_min'] = $minimum; $storedBranch['value_max'] = $maximum; } else { $valueRaw = trim( (string)( $postedBranch['value'] ?? '' ) ); if ( $targetDataType === 'number' && ( $valueRaw === '' || !is_numeric($valueRaw) ) ) { return [ 'effects' => [], 'error' => 'Numeric Tick Effect results require a numeric value.', ]; } if ( $targetDataType === 'boolean' && !in_array( strtolower($valueRaw), ['0', '1', 'true', 'false'], true ) ) { return [ 'effects' => [], 'error' => 'Boolean Tick Effect results must use one of the available values.', ]; } if ($targetDataType === 'boolean') { $lowerValue = strtolower($valueRaw); $storedBranch['value'] = ($lowerValue === '1' || $lowerValue === 'true') ? 1 : 0; } else { $storedBranch['value'] = is_numeric($valueRaw) ? gameplayEffectNormalizeNumber( $valueRaw ) : $valueRaw; } } } $branches[] = $storedBranch; } if ($branches === []) { return [ 'effects' => [], 'error' => 'Each Tick Effect must have at least one result.', ]; } /* * Keep a legacy top-level projection until runtime is upgraded. * If an OTHERWISE branch exists, it is the safest representation of * the current base behavior. Otherwise use the first branch, matching * the pre-v28 single-result behavior. * * "No Change" projects as Add 0 because the old runtime does not know * the v28 "none" operation yet. */ $compatibilityBranch = $branches[0]; foreach ($branches as $candidateBranch) { if ( gameplayTickEffectBranchIsOtherwise( $candidateBranch ) ) { $compatibilityBranch = $candidateBranch; break; } } $compatibilityOperation = (string)( $compatibilityBranch['operation'] ?? 'add' ); $storedEffect = [ 'target_scope' => $targetDefinition['target_scope'], 'target_type' => $targetDefinition['target_type'], 'target_id' => $targetDefinition['target_id'], 'operation' => $compatibilityOperation === 'none' ? 'add' : $compatibilityOperation, 'value' => $compatibilityOperation === 'none' ? 0 : ( $compatibilityBranch['value'] ?? 0 ), 'branches' => $branches, ]; if ( $compatibilityOperation !== 'none' && ($compatibilityBranch['value_mode'] ?? 'fixed') === 'random' ) { $storedEffect['value_mode'] = 'random'; $storedEffect['value_min'] = $compatibilityBranch['value_min'] ?? 0; $storedEffect['value_max'] = $compatibilityBranch['value_max'] ?? 0; } if ( !gameplayTickEffectBranchIsOtherwise( $compatibilityBranch ) ) { $storedEffect['conditions'] = $compatibilityBranch['conditions']; } $effects[] = $storedEffect; } return [ 'effects' => $effects, 'error' => '', ]; } function gameplayTickEffectSelected( array $effect, string $scope, string $targetId ): bool { if ( isset($effect['target_scope']) || isset($effect['target_id']) ) { return (string)( $effect['target_scope'] ?? '' ) === $scope && (string)( $effect['target_id'] ?? '' ) === $targetId; } return (string)( $effect['target'] ?? '' ) === $scope . ':' . $targetId; } function gameplayTickEffectValue( array $effect ) { return $effect['value'] ?? ''; } function gameplayTickEffectOperation( array $effect ): string { return (string)( $effect['operation'] ?? 'add' ); } /* |-------------------------------------------------------------------------- | Shared condition authoring support |-------------------------------------------------------------------------- | | WAYFARER_CONDITION_EDITOR_V27 | */ /* |-------------------------------------------------------------------------- | Universal Runtime-State Targets | WAYFARER_UNIVERSAL_RUNTIME_TARGETS_V56 |-------------------------------------------------------------------------- | | Every Condition and general Effect editor should see the same addressable | runtime state. Authoring metadata (min/max, display flags, type declarations, | visibility rules, etc.) is intentionally NOT exposed as a target. | */ function gameplayRuntimeStateDecodeConfig($raw): array { if (is_array($raw)) { return $raw; } if ($raw === null || $raw === '') { return []; } $decoded = json_decode( (string)$raw, true ); return is_array($decoded) ? $decoded : []; } function gameplayRuntimeStateAddTraitTarget( array &$targets, string $scope, string $group, string $ownerLabel, array $trait, ?string $displayName = null, ?string $systemKey = null ): void { if ( array_key_exists('active', $trait) && !$trait['active'] ) { return; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { return; } $traitName = trim( (string)( $displayName ?? $trait['name'] ?? $traitId ) ); if ($traitName === '') { $traitName = $traitId; } $key = $scope . ':trait:' . $traitId; /* WAYFARER_TARGET_METADATA_V58 */ $traitType = strtolower( trim( (string)( $trait['type'] ?? 'text' ) ) ); $dataType = in_array( $traitType, ['boolean', 'binary'], true ) ? 'boolean' : $traitType; $semanticType = $systemKey === 'quantity' ? 'item_quantity' : ( $systemKey === 'party_member' ? 'party_membership' : 'trait' ); $helper = $semanticType === 'party_membership' ? '1 = Joined / 0 = Not Joined' : ( $dataType === 'boolean' ? '1 = True / 0 = False' : ( $dataType === 'number' ? 'Number' : ucfirst($dataType) ) ); $conditionOperators = $dataType === 'number' ? wayfarerConditionOperators() : [ 'equals', 'not_equals', ]; $effectOperations = $dataType === 'number' ? [ 'set', 'add', 'subtract', ] : [ 'set', ]; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => $traitType, 'data_type' => $dataType, 'semantic_type' => $semanticType, 'helper' => $helper, 'condition_operators' => $conditionOperators, 'effect_operations' => $effectOperations, 'supports_random' => $dataType === 'number', 'group' => $group, 'label' => $ownerLabel . ' → ' . $traitName, ]; if ($dataType === 'boolean') { $targets[$key]['value_options'] = $semanticType === 'party_membership' ? [ '0' => 'Not Joined', '1' => 'Joined', ] : [ '0' => 'False', '1' => 'True', ]; } if ($systemKey !== null && $systemKey !== '') { $targets[$key]['target_system_key'] = $systemKey; } } /* * WAYFARER_CANONICAL_REGION_TARGETS_V68 * * Region authoring now uses explicit universal Region identities such as * region:12:trait:weather. The bare "region" scope remains runtime-compatible * only for legacy data. These helpers convert that legacy shape at the Region * editor/save boundary so every new save becomes canonical. */ function gameplayCanonicalRegionTargetKey( string $targetKey, int $regionId ): string { $targetKey = trim($targetKey); if ($targetKey === '' || $regionId <= 0) { return $targetKey; } if ( preg_match( '/^region:trait:(.+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } if ( preg_match( '/^region:([^:]+)$/', $targetKey, $match ) === 1 ) { return 'region:' . $regionId . ':trait:' . $match[1]; } return $targetKey; } function gameplayCanonicalizeLegacyCurrentRegionConditionNode( $node, int $regionId ) { if (!is_array($node) || $regionId <= 0) { return $node; } $type = (string)( $node['type'] ?? '' ); if ($type === 'condition') { if (isset($node['target'])) { $node['target'] = gameplayCanonicalRegionTargetKey( (string)$node['target'], $regionId ); } if ( (string)($node['scope'] ?? '') === 'region' && trim((string)($node['target_id'] ?? '')) !== '' ) { $node['scope'] = 'region:' . $regionId; if ( trim((string)($node['target_type'] ?? '')) === '' ) { $node['target_type'] = 'trait'; } } return $node; } if ( isset($node['children']) && is_array($node['children']) ) { foreach ( $node['children'] as $index => $child ) { $node['children'][$index] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $child, $regionId ); } } return $node; } function gameplayCanonicalizeLegacyCurrentRegionTickEffects( array $effects, int $regionId ): array { if ($regionId <= 0) { return $effects; } foreach ( $effects as $effectIndex => $effect ) { if (!is_array($effect)) { continue; } if (isset($effect['target'])) { $effect['target'] = gameplayCanonicalRegionTargetKey( (string)$effect['target'], $regionId ); } if ( (string)($effect['target_scope'] ?? '') === 'region' && trim((string)($effect['target_id'] ?? '')) !== '' ) { $effect['target_scope'] = 'region:' . $regionId; if ( trim((string)($effect['target_type'] ?? '')) === '' ) { $effect['target_type'] = 'trait'; } } if ( isset($effect['conditions']) && is_array($effect['conditions']) ) { $effect['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $effect['conditions'], $regionId ); } if ( isset($effect['branches']) && is_array($effect['branches']) ) { foreach ( $effect['branches'] as $branchIndex => $branch ) { if (!is_array($branch)) { continue; } if ( isset($branch['conditions']) && is_array($branch['conditions']) ) { $branch['conditions'] = gameplayCanonicalizeLegacyCurrentRegionConditionNode( $branch['conditions'], $regionId ); } $effect['branches'][$branchIndex] = $branch; } } $effects[$effectIndex] = $effect; } return $effects; } function gameplayRuntimeStateTargets( PDO $pdo, int $gameId, ?array $gameTraitsOverride = null, ?array $playerTraitsOverride = null, ?int $playerSlotsOverride = null ): array { $targets = []; /* * Navigation Position is runtime state rather than a Trait, but it belongs * in the same searchable state catalog. It may simply be unresolved in a * playthrough/navigation mode where Position does not exist. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'effect_operations' => ['set', 'add', 'subtract'], 'supports_random' => true, 'group' => 'Navigation', 'label' => 'Navigation → Position', ]; $gameConfig = []; $playerSlots = $playerSlotsOverride; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config, player_slots FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $gameRow = $stmt->fetch( PDO::FETCH_ASSOC ); if ($gameRow) { $gameConfig = gameplayRuntimeStateDecodeConfig( $gameRow['config'] ?? null ); if ($playerSlots === null) { $playerSlots = max( 0, (int)( $gameRow['player_slots'] ?? 0 ) ); } } } $gameTraits = $gameTraitsOverride !== null ? $gameTraitsOverride : ( isset($gameConfig['traits']) && is_array($gameConfig['traits']) ? $gameConfig['traits'] : [] ); $playerTraits = $playerTraitsOverride !== null ? $playerTraitsOverride : ( isset($gameConfig['player_traits']) && is_array($gameConfig['player_traits']) ? $gameConfig['player_traits'] : [] ); $playerSlots = max( 0, (int)($playerSlots ?? 0) ); foreach ($gameTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'game', 'Game Traits', 'Game', $trait ); } for ($slot = 0; $slot < $playerSlots; $slot++) { foreach ($playerTraits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'player:' . $slot, 'Player Character ' . ($slot + 1), 'Player ' . ($slot + 1), $trait ); } } if ($gameId <= 0) { return $targets; } $stmt = $pdo->prepare( " SELECT id, name, config FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Region ' . $id) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'region:' . $id, 'Region — ' . $name, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT l.id, l.name, l.config, r.name AS region_name FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Location ' . $id) ) ); $regionName = trim( (string)( $row['region_name'] ?? '' ) ); $group = 'Location — ' . ( $regionName !== '' ? $regionName . ' / ' : '' ) . $name; $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'location:' . $id, $group, $name, $trait ); } } $stmt = $pdo->prepare( " SELECT id, name, entity_type, config FROM wf_entities WHERE game_id = ? ORDER BY entity_type, name, id " ); $stmt->execute([ $gameId ]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (int)($row['id'] ?? 0); if ($id <= 0) { continue; } $name = trim( (string)( $row['name'] ?? ('Entity ' . $id) ) ); $entityType = strtolower( trim( (string)( $row['entity_type'] ?? '' ) ) ); $config = gameplayRuntimeStateDecodeConfig( $row['config'] ?? null ); if ($entityType === 'item') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Items', $name, [ 'id' => 'sys_item_quantity', 'name' => 'Quantity', 'type' => 'number', 'active' => true, ], 'Quantity', 'quantity' ); } if ($entityType === 'character') { gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, 'Characters', $name, [ 'id' => 'sys_character_party_member', 'name' => 'Party Membership', 'type' => 'binary', 'active' => true, ], 'Party Membership', 'party_member' ); } $traits = isset($config['traits']) && is_array($config['traits']) ? $config['traits'] : []; foreach ($traits as $trait) { if ( !is_array($trait) || !empty($trait['system']) ) { continue; } gameplayRuntimeStateAddTraitTarget( $targets, 'entity:' . $id, $entityType === 'character' ? 'Characters' : ( $entityType === 'item' ? 'Items' : 'Entities' ), $name, $trait ); } } return $targets; } function gameplayConditionTraitTargets( array $traits, string $scope, string $groupLabel ): array { $targets = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } if ( isset( $trait['active'] ) && !$trait['active'] ) { continue; } $traitId = trim( (string)( $trait['id'] ?? '' ) ); if ($traitId === '') { continue; } $key = $scope . ':trait:' . $traitId; $targets[$key] = [ 'scope' => $scope, 'target_type' => 'trait', 'target_id' => $traitId, 'trait_type' => (string)( $trait['type'] ?? 'text' ), 'group' => $groupLabel, 'label' => (string)( $trait['name'] ?? $traitId ), ]; } return $targets; } function gameplayConditionAllowedTargets( array $gameTraits, array $scopeTraits = [], string $scope = '', string $scopeLabel = '' ): array { $targets = gameplayConditionTraitTargets( $gameTraits, 'game', 'Game Traits' ); if ($scope !== '') { $targets = array_merge( $targets, gameplayConditionTraitTargets( $scopeTraits, $scope, $scopeLabel !== '' ? $scopeLabel . ' Traits' : ucfirst($scope) . ' Traits' ) ); } return $targets; } /* |-------------------------------------------------------------------------- | Game-wide condition targets |-------------------------------------------------------------------------- | WAYFARER_GAME_WIDE_CONDITION_TARGETS_V46 | | Global rules may inspect persistent state anywhere in the Game. | | Game Traits use their existing scope: | game | | A specific Region's Traits use: | region: | | The existing plain "region" scope is intentionally left untouched. It | continues to mean the context Region for Region/Location-scoped rules. | */ /* WAYFARER_GAME_WIDE_TARGETS_ARE_UNIVERSAL_V56 */ /* |-------------------------------------------------------------------------- | Navigation Condition Targets |-------------------------------------------------------------------------- | WAYFARER_LOCATION_CONDITION_TARGETS_V75 | WAYFARER_LIVE_LOCATION_ANCHORS_V76 | | Numeric Position Conditions are Region-qualified. | | New Location Conditions reference wf_locations.id as target_id. The | comparison itself is against zero because runtime resolves the target to: | | current position - current anchor position | | That makes a Location a live spatial anchor: | | = 0 => At | != 0 => Not at | < 0 => Before | <= 0 => At or before | > 0 => After | >= 0 => At or after | | The V75 snapshot target navigation:location:location remains hidden for | backward compatibility only. | */ function gameplayConditionNavigationPositionToken( $value ): string { if (!is_numeric($value)) { return ''; } $number = (float)$value; if (is_finite($number) && floor($number) == $number) { return (string)((int)$number); } $token = rtrim( rtrim( sprintf('%.12F', $number), '0' ), '.' ); return $token === '-0' ? '0' : $token; } function gameplayNavigationConditionTargets( PDO $pdo, int $gameId ): array { $targets = []; /* * Compatibility only. Existing saved unqualified Position Conditions * still render and save, but cannot be newly authored. */ $targets['navigation:position:position'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_position_legacy', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'group' => 'Navigation', 'label' => 'Position (legacy unqualified)', 'condition_hidden' => true, ]; /* * Compatibility only. V75 stored Region + Position in the value itself. * New V76 Conditions never use this target. */ $targets['navigation:location:location'] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => 'location', 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'navigation_location_v75_snapshot', 'helper' => 'V75 snapshot', 'condition_operators' => [ 'equals', 'not_equals', ], 'group' => 'Navigation', 'label' => 'Location (legacy V75 snapshot)', 'condition_hidden' => true, ]; if ($gameId <= 0) { return $targets; } /* * Fixed numeric Position, explicitly qualified to one linear Region. */ $regionStmt = $pdo->prepare( " SELECT id, name, navigation_type FROM wf_regions WHERE game_id = ? ORDER BY name, id " ); $regionStmt->execute([ $gameId ]); foreach ( $regionStmt->fetchAll(PDO::FETCH_ASSOC) as $region ) { $regionId = (int)($region['id'] ?? 0); if ($regionId <= 0) { continue; } if ( strtolower( trim( (string)( $region['navigation_type'] ?? '' ) ) ) !== 'linear' ) { continue; } $regionName = trim( (string)( $region['name'] ?? ('Region ' . $regionId) ) ); $scope = 'navigation:' . $regionId; $key = $scope . ':position:position'; $targets[$key] = [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_region_position', 'helper' => 'Number', 'condition_operators' => wayfarerConditionOperators(), 'group' => 'Navigation — Positions', 'label' => $regionName, ]; } /* * Named Locations are LIVE references. Each Location is its own target, * identified by wf_locations.id. Position is shown only as authoring help; * it is resolved again from current Location config at runtime. */ $locationStmt = $pdo->prepare( " SELECT l.id, l.name, l.config, l.region_id, r.name AS region_name, r.navigation_type FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? ORDER BY r.name, l.name, l.id " ); $locationStmt->execute([ $gameId ]); foreach ( $locationStmt->fetchAll(PDO::FETCH_ASSOC) as $location ) { if ( strtolower( trim( (string)( $location['navigation_type'] ?? '' ) ) ) !== 'linear' ) { continue; } $locationId = (int)( $location['id'] ?? 0 ); if ($locationId <= 0) { continue; } $config = gameplayRuntimeStateDecodeConfig( $location['config'] ?? null ); if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } $position = gameplayConditionNavigationPositionToken( $config['position'] ); if ($position === '') { continue; } $locationName = trim( (string)( $location['name'] ?? ('Location ' . $locationId) ) ); $regionName = trim( (string)( $location['region_name'] ?? 'Region' ) ); $key = 'navigation:location:' . $locationId; $targets[$key] = [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => (string)$locationId, 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_location_anchor', 'helper' => $regionName . ' · Position ' . $position, 'condition_operators' => [ 'equals', 'not_equals', 'less_than', 'less_than_or_equal', 'greater_than', 'greater_than_or_equal', ], 'condition_operator_labels' => [ 'equals' => 'At', 'not_equals' => 'Not at', 'less_than' => 'Before', 'less_than_or_equal' => 'At or before', 'greater_than' => 'After', 'greater_than_or_equal' => 'At or after', ], /* * The runtime value is a signed delta from this anchor, so every * comparison is against zero. Keep that implementation detail out * of the authoring UI while still posting a normal Condition value. */ 'value_options' => [ '0' => 'Anchor', ], 'condition_hide_value' => true, 'group' => 'Navigation — Locations', 'label' => $locationName, ]; } return $targets; } /* * Allows Condition POST validation to recognize V76 navigation-only target * shapes even when a caller supplied the older universal Effect catalog. */ function gameplaySyntheticNavigationConditionTarget( string $target ): ?array { if ( preg_match( '/^navigation:([1-9][0-9]*):position:position$/', $target, $match ) === 1 ) { $scope = 'navigation:' . (int)$match[1]; return [ 'scope' => $scope, 'target_scope' => $scope, 'target_type' => 'position', 'target_id' => 'position', 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_region_position', 'condition_operators' => wayfarerConditionOperators(), ]; } if ( preg_match( '/^navigation:location:([1-9][0-9]*)$/', $target, $match ) === 1 ) { return [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => (string)((int)$match[1]), 'trait_type' => 'number', 'data_type' => 'number', 'semantic_type' => 'navigation_location_anchor', 'condition_operators' => [ 'equals', 'not_equals', 'less_than', 'less_than_or_equal', 'greater_than', 'greater_than_or_equal', ], ]; } /* V75 snapshot compatibility. */ if ($target === 'navigation:location:location') { return [ 'scope' => 'navigation', 'target_scope' => 'navigation', 'target_type' => 'location', 'target_id' => 'location', 'trait_type' => 'text', 'data_type' => 'text', 'semantic_type' => 'navigation_location_v75_snapshot', 'condition_operators' => [ 'equals', 'not_equals', ], ]; } return null; } function gameplayConditionGameWideTargets( PDO $pdo, int $gameId, array $gameTraits ): array { return array_replace( gameplayRuntimeStateTargets( $pdo, $gameId, $gameTraits, null, null ), gameplayNavigationConditionTargets( $pdo, $gameId ) ); } function gameplayBuildConditionNodeFromPost( $postedNode, array $allowedTargets, int $depth = 0 ): array { if ( $postedNode === null || $postedNode === [] ) { return [ 'node' => null, 'error' => '', ]; } if ( $depth > 20 || !is_array( $postedNode ) ) { return [ 'node' => null, 'error' => 'Invalid condition structure.', ]; } $type = trim( (string)( $postedNode['type'] ?? '' ) ); if ($type === 'group') { $mode = trim( (string)( $postedNode['mode'] ?? 'all' ) ); if ( !in_array( $mode, wayfarerConditionGroupModes(), true ) ) { return [ 'node' => null, 'error' => 'Condition groups must use ALL or ANY.', ]; } $postedChildren = $postedNode['children'] ?? []; if (!is_array($postedChildren)) { return [ 'node' => null, 'error' => 'Invalid condition group.', ]; } $children = []; foreach ($postedChildren as $postedChild) { $result = gameplayBuildConditionNodeFromPost( $postedChild, $allowedTargets, $depth + 1 ); if ($result['error'] !== '') { return $result; } if ($result['node'] !== null) { $children[] = $result['node']; } } /* * An empty root/group is equivalent to no conditions. */ if ($children === []) { return [ 'node' => null, 'error' => '', ]; } return [ 'node' => [ 'type' => 'group', 'mode' => $mode, 'children' => $children, ], 'error' => '', ]; } if ($type === 'condition') { $target = trim( (string)( $postedNode['target'] ?? '' ) ); $operator = trim( (string)( $postedNode['operator'] ?? '' ) ); $value = $postedNode['value'] ?? ''; /* * WAYFARER_LOCATION_CONDITION_TARGETS_V75 * * Older callers may still pass the universal Effect target catalog. * Region-qualified Position and Location targets are Condition-only, * so synthesize their definitions here when needed. */ if ( $target !== '' && !isset($allowedTargets[$target]) ) { $syntheticTarget = gameplaySyntheticNavigationConditionTarget( $target ); if ($syntheticTarget !== null) { $allowedTargets[$target] = $syntheticTarget; } } if ( $target === '' && trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => '', ]; } if ( $target === '' || !isset( $allowedTargets[$target] ) ) { return [ 'node' => null, 'error' => 'Each condition must use a valid target.', ]; } if ( !in_array( $operator, wayfarerConditionOperators(), true ) ) { return [ 'node' => null, 'error' => 'Invalid condition comparison.', ]; } if ( trim( (string)$value ) === '' ) { return [ 'node' => null, 'error' => 'Each condition must include a comparison value.', ]; } $definition = $allowedTargets[$target]; /* WAYFARER_DATATYPE_CONDITION_VALIDATION_V58 */ $conditionOperators = isset($definition['condition_operators']) && is_array($definition['condition_operators']) ? array_values($definition['condition_operators']) : wayfarerConditionOperators(); if (!in_array($operator, $conditionOperators, true)) { return [ 'node' => null, 'error' => 'That comparison is not valid for the selected condition target.', ]; } $conditionDataType = strtolower( trim( (string)( $definition['data_type'] ?? $definition['trait_type'] ?? 'text' ) ) ); if ($conditionDataType === 'binary') { $conditionDataType = 'boolean'; } if ( $conditionDataType === 'number' && !is_numeric($value) ) { return [ 'node' => null, 'error' => 'Numeric conditions require a numeric comparison value.', ]; } if ( $conditionDataType === 'boolean' && !in_array( strtolower(trim((string)$value)), ['0', '1', 'true', 'false'], true ) ) { return [ 'node' => null, 'error' => 'Boolean conditions must use one of the available values.', ]; } $normalizedConditionValue = $conditionDataType === 'boolean' ? ( in_array( strtolower(trim((string)$value)), ['1', 'true'], true ) ? 1 : 0 ) : wayfarerConditionNormalizeScalar( $value ); return [ 'node' => [ 'type' => 'condition', 'scope' => $definition['scope'], 'target_type' => $definition['target_type'], 'target_id' => $definition['target_id'], 'operator' => $operator, 'value' => $normalizedConditionValue, ], 'error' => '', ]; } return [ 'node' => null, 'error' => 'Invalid condition node.', ]; } ================================================================================ FILE: updates/backups/v80-hierarchical-targets-20260929-140949/includes/gameplay/tick-effects-editor.php ================================================================================ Select target...'; if ($gameplayTargetCatalog) { $selectedScope = (string)( $tickEffect['target_scope'] ?? '' ); $selectedType = (string)( $tickEffect['target_type'] ?? '' ); $selectedId = (string)( $tickEffect['target_id'] ?? '' ); $selectedKey = $selectedScope . ':' . $selectedType . ':' . $selectedId; /* * WAYFARER_POSTED_TICK_TARGET_PRESERVE_V67 * * Failed POSTs are intentionally re-rendered from their submitted * rows. Submitted rows use the form field "target" rather than the * canonical stored target_scope / target_type / target_id fields. */ $postedSelectedKey = trim( (string)( $tickEffect['target'] ?? '' ) ); if ( $postedSelectedKey !== '' && isset( $gameplayTargetCatalog[ $postedSelectedKey ] ) ) { $selectedKey = $postedSelectedKey; } /* * WAYFARER_LEGACY_TICK_TARGET_SELECTION_V65 * * Pre-universal-target Tick Effects did not always store * target_type. Infer the canonical catalog key for display so the * existing Effect remains selected instead of silently posting the * blank first option on the next Region save. */ if ( $selectedScope !== '' && $selectedType === '' && $selectedId !== '' ) { foreach ( $gameplayTargetCatalog as $catalogKey => $catalogTarget ) { if (!is_array($catalogTarget)) { continue; } if ( (string)( $catalogTarget[ 'target_scope' ] ?? '' ) === $selectedScope && (string)( $catalogTarget[ 'target_id' ] ?? '' ) === $selectedId ) { $selectedKey = (string)$catalogKey; break; } } } $grouped = []; foreach ( $gameplayTargetCatalog as $value => $target ) { if (!is_array($target)) { continue; } $group = (string)( $target['group'] ?? 'Targets' ); if (!isset($grouped[$group])) { $grouped[$group] = []; } $grouped[$group][$value] = $target; } foreach ($grouped as $group => $options) { echo ''; foreach ($options as $value => $target) { echo ''; } echo ''; } return; } if ($gameplayAllowNavigation) { echo ''; echo ''; echo ''; } if ($gameplayGameTraitTargets) { echo ''; foreach ( $gameplayGameTraitTargets as $targetTrait ) { echo ''; } echo ''; } if ( $gameplayScopeKey !== '' && $gameplayScopeTraitTargets ) { echo ''; foreach ( $gameplayScopeTraitTargets as $targetTrait ) { echo ''; } echo ''; } }; $renderBranchResultFields = function ( array $branch, string $name ): void { $operation = gameplayTickEffectBranchOperation( $branch ); $value = gameplayTickEffectBranchValue( $branch ); $valueMode = gameplayTickEffectBranchValueMode( $branch ); $minimum = gameplayTickEffectBranchMin( $branch ); $maximum = gameplayTickEffectBranchMax( $branch ); ?>
data-gameplay-scope="" data-navigation-type="" >

Tick Effects

Each Tick Effect controls one target. Its results are checked from top to bottom. The first matching result wins within that Tick Effect. Separate Tick Effects remain independent.

$tickEffect ): ?>
$branch ): ?>
THEN

================================================================================ FILE: updates/backups/v80-hierarchical-targets-20260929-140949/play/includes/effects.php ================================================================================ true, 'value' => wayfarerNormalizeRuntimeNumber( (float)$effectValue ), 'error' => '', ]; } return [ 'applied' => true, 'value' => $effectValue, 'error' => '', ]; } if ( $operation !== 'add' && $operation !== 'subtract' ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Unsupported operation: ' . $operation, ]; } if ( !is_numeric($currentValue) || !is_numeric($effectValue) ) { return [ 'applied' => false, 'value' => $currentValue, 'error' => 'Add/subtract requires numeric values.', ]; } $current = (float)$currentValue; $amount = (float)$effectValue; $result = $operation === 'add' ? $current + $amount : $current - $amount; return [ 'applied' => true, 'value' => wayfarerNormalizeRuntimeNumber( $result ), 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Tick Effect branch runtime |-------------------------------------------------------------------------- | | A Tick Effect owns one target and an ordered list of result branches. | Conditions are evaluated against the CURRENT runtime state. Because Tick | Effects execute sequentially, a later Tick Effect can see state changes made | by an earlier Tick Effect during the same tick. | */ function wayfarerRuntimeResolveConditionTarget( array $runtimeState, int $currentRegionId, string $scope, string $targetType, string $targetId ): array { $scope = strtolower( trim( $scope ) ); $targetType = strtolower( trim( $targetType ) ); $targetId = trim( $targetId ); // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $targetType === 'trait' && $targetId !== '' ) { $traits = $runtimeState['game']['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // SPECIFIC REGION TRAIT // WAYFARER_SPECIFIC_REGION_CONDITION_SCOPE_V46 // ------------------------------------------------------------ // // Game-level condition editors store a Region-owned Trait as // "region:". This lets a global rule inspect that Region whether // or not the player is currently there. // if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^region:([1-9][0-9]*)$/', $scope, $regionScopeMatch ) === 1 ) { $regionKey = (string)( (int)$regionScopeMatch[1] ); $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $targetType === 'trait' && $targetId !== '' && $currentRegionId > 0 ) { $regionKey = (string)$currentRegionId; $traits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists( $targetId, $traits ) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // PLAYER / LOCATION / ENTITY TRAITS // WAYFARER_RUNTIME_UNIVERSAL_TARGET_RESOLVER_V56 // ------------------------------------------------------------ if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^player:([0-9]+)$/', $scope, $playerScopeMatch ) === 1 ) { $playerKey = (string)((int)$playerScopeMatch[1]); $traits = $runtimeState['players'][$playerKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^location:([1-9][0-9]*)$/', $scope, $locationScopeMatch ) === 1 ) { $locationKey = (string)((int)$locationScopeMatch[1]); $traits = $runtimeState['locations'][$locationKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } if ( $targetType === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityScopeMatch ) === 1 ) { $entityKey = (string)((int)$entityScopeMatch[1]); $traits = $runtimeState['entities'][$entityKey]['traits'] ?? null; if ( is_array($traits) && array_key_exists($targetId, $traits) ) { return [ 'found' => true, 'value' => $traits[$targetId], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // REGION-QUALIFIED NAVIGATION POSITION // WAYFARER_LOCATION_CONDITION_TARGETS_V75 // ------------------------------------------------------------ if ( $targetType === 'position' && $targetId === 'position' && preg_match( '/^navigation:([1-9][0-9]*)$/', $scope, $navigationRegionMatch ) === 1 ) { $requiredRegionId = (int)$navigationRegionMatch[1]; if ( $requiredRegionId !== $currentRegionId ) { return [ 'found' => false, 'value' => null, ]; } if ( isset( $runtimeState['navigation'] ) && is_array( $runtimeState['navigation'] ) && array_key_exists( 'position', $runtimeState['navigation'] ) ) { return [ 'found' => true, 'value' => $runtimeState['navigation']['position'], ]; } return [ 'found' => false, 'value' => null, ]; } // ------------------------------------------------------------ // LIVE LOCATION ANCHOR // WAYFARER_LIVE_LOCATION_ANCHORS_V76 // ------------------------------------------------------------ if ( $scope === 'navigation' && $targetType === 'location' && preg_match( '/^[1-9][0-9]*$/', $targetId ) === 1 ) { $locationKey = (string)((int)$targetId); $anchor = $runtimeState['locations'][$locationKey] ?? null; if (!is_array($anchor)) { return [ 'found' => false, 'value' => null, ]; } $anchorRegionId = (int)( $anchor['region_id'] ?? 0 ); $anchorPosition = $anchor['position'] ?? null; if ( $anchorRegionId <= 0 || !is_numeric($anchorPosition) ) { return [ 'found' => false, 'value' => null, ]; } /* * Before/After has no meaning across Regions. "Not at" is handled by * the Condition engine via comparison_scope_match=false. */ if ($anchorRegionId !== $currentRegionId) { return [ 'found' => true, 'value' => null, 'comparison_scope_match' => false, ]; } $currentPosition = $runtimeState['navigation']['position'] ?? null; if (!is_numeric($currentPosition)) { return [ 'found' => false, 'value' => null, ]; } return [ 'found' => true, 'value' => wayfarerNormalizeRuntimeNumber( (float)$currentPosition - (float)$anchorPosition ), 'comparison_scope_match' => true, ]; } // ------------------------------------------------------------ // NAMED LOCATION = CURRENT REGION + CURRENT NUMERIC POSITION // WAYFARER_LOCATION_CONDITION_TARGETS_V75 // ------------------------------------------------------------ if ( $scope === 'navigation' && $targetType === 'location' && $targetId === 'location' ) { if ( $currentRegionId <= 0 || !isset( $runtimeState['navigation'] ) || !is_array( $runtimeState['navigation'] ) || !array_key_exists( 'position', $runtimeState['navigation'] ) || !is_numeric( $runtimeState['navigation']['position'] ) ) { return [ 'found' => false, 'value' => null, ]; } $position = wayfarerNormalizeRuntimeNumber( (float)$runtimeState[ 'navigation' ]['position'] ); return [ 'found' => true, 'value' => $currentRegionId . ':' . (string)$position, ]; } // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $targetType === 'position' && $targetId === 'position' ) { if ( isset( $runtimeState['navigation'] ) && is_array( $runtimeState['navigation'] ) && array_key_exists( 'position', $runtimeState['navigation'] ) ) { return [ 'found' => true, 'value' => $runtimeState['navigation']['position'], ]; } return [ 'found' => false, 'value' => null, ]; } return [ 'found' => false, 'value' => null, ]; } function wayfarerRuntimeTickEffectBranches( array $effect ): array { /* * v28+ canonical structure. * * If the branches key exists, it is authoritative. An empty branches * array therefore means "this effect has no executable results" rather * than silently falling back to legacy compatibility fields. */ if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * Legacy pre-v28 structure. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'conditions', $effect ) ) { $branch = [ 'conditions' => $effect['conditions'] ?? null, 'operation' => $effect['operation'] ?? '', 'value_mode' => $effect['value_mode'] ?? 'fixed', ]; if ( strtolower( trim( (string)$branch['value_mode'] ) ) === 'random' ) { $branch['value_min'] = $effect['value_min'] ?? 0; $branch['value_max'] = $effect['value_max'] ?? 0; } else { $branch['value'] = $effect['value'] ?? 0; } return [ $branch ]; } return []; } function wayfarerRuntimeSelectTickEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeTickEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Tick Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; /* * Validate before evaluating. Invalid authored data fails closed and * does NOT fall through to OTHERWISE, because doing so could silently * produce the wrong gameplay result. */ $validation = wayfarerConditionValidateNode( $conditions ); if (!$validation['valid']) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Tick Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } /* |-------------------------------------------------------------------------- | Runtime Trait bounds |-------------------------------------------------------------------------- | | WAYFARER_RUNTIME_TRAIT_BOUNDS_V30 | | Authored numeric Trait min/max values are runtime invariants. | State is normalized before condition evaluation and every subsequent | mutation is clamped again before it is written. | */ function wayfarerRuntimeDecodeTraitConfig( $rawConfig ): array { if (is_array($rawConfig)) { return $rawConfig; } if ( $rawConfig === null || $rawConfig === '' ) { return []; } $decoded = json_decode( (string)$rawConfig, true ); return is_array($decoded) ? $decoded : []; } function wayfarerRuntimeIndexTraitDefinitions( array $traits ): array { $definitions = []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } $traitId = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($traitId === '') { continue; } $definitions[$traitId] = $trait; } return $definitions; } function wayfarerRuntimeLoadTickTraitDefinitions( PDO $pdo, int $gameId, array $regionConfig ): array { $gameConfig = []; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawGameConfig = $stmt->fetchColumn(); if ($rawGameConfig !== false) { $gameConfig = wayfarerRuntimeDecodeTraitConfig( $rawGameConfig ); } } /* WAYFARER_RUNTIME_UNIVERSAL_DEFINITIONS_V56 */ $definitions = [ 'game' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['traits'] ?? null ) ? $gameConfig['traits'] : [] ), 'player' => wayfarerRuntimeIndexTraitDefinitions( is_array( $gameConfig['player_traits'] ?? null ) ? $gameConfig['player_traits'] : [] ), 'region' => wayfarerRuntimeIndexTraitDefinitions( is_array( $regionConfig['traits'] ?? null ) ? $regionConfig['traits'] : [] ), 'regions' => [], 'locations' => [], 'entities' => [], ]; if ($gameId > 0) { $stmt = $pdo->prepare( " SELECT id, config FROM wf_regions WHERE game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $definitions['regions'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } $stmt = $pdo->prepare( " SELECT l.id, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE r.game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $definitions['locations'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } $stmt = $pdo->prepare( " SELECT id, entity_type, config FROM wf_entities WHERE game_id = ? " ); $stmt->execute([$gameId]); foreach ( $stmt->fetchAll(PDO::FETCH_ASSOC) as $row ) { $id = (string)((int)($row['id'] ?? 0)); if ($id === '0') { continue; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); if ( function_exists( 'wayfarerEntityActiveTraitDefinitions' ) ) { $definitions['entities'][$id] = wayfarerEntityActiveTraitDefinitions( $config, (string)( $row['entity_type'] ?? '' ) ); } else { $definitions['entities'][$id] = wayfarerRuntimeIndexTraitDefinitions( is_array($config['traits'] ?? null) ? $config['traits'] : [] ); } } } return $definitions; } function wayfarerRuntimeClampValueToTraitDefinition( $value, ?array $definition ): array { $result = [ 'valid' => true, 'value' => $value, 'clamped' => false, 'min' => null, 'max' => null, 'error' => '', ]; /* * Bounds currently belong only to authored Number Traits. */ if ( !$definition || strtolower( trim( (string)( $definition['type'] ?? '' ) ) ) !== 'number' ) { return $result; } if (!is_numeric($value)) { $result['valid'] = false; $result['error'] = 'Numeric Trait runtime state must be numeric.'; return $result; } $min = isset($definition['min']) && $definition['min'] !== '' && is_numeric($definition['min']) ? (float)$definition['min'] : null; $max = isset($definition['max']) && $definition['max'] !== '' && is_numeric($definition['max']) ? (float)$definition['max'] : null; $numeric = (float)$value; $bounded = $numeric; if ( $min !== null && $bounded < $min ) { $bounded = $min; } if ( $max !== null && $bounded > $max ) { $bounded = $max; } $result['value'] = wayfarerNormalizeRuntimeNumber( $bounded ); $result['clamped'] = $bounded !== $numeric; $result['min'] = $min === null ? null : wayfarerNormalizeRuntimeNumber( $min ); $result['max'] = $max === null ? null : wayfarerNormalizeRuntimeNumber( $max ); return $result; } function wayfarerRuntimeApplyBoundedTraitOperation( $storedBefore, string $operation, $effectValue, ?array $definition ): array { /* * Repair an already-illegal persisted value BEFORE applying the new * operation. Example: 120 with max 100 and subtract 10 becomes * 100 - 10 = 90, not 120 - 10 = 110 -> 100. */ $beforeBound = wayfarerRuntimeClampValueToTraitDefinition( $storedBefore, $definition ); if (!$beforeBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $storedBefore, 'requested_after' => $storedBefore, 'after' => $storedBefore, 'clamped' => false, 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $beforeBound['error'], ]; } $normalizedBefore = $beforeBound['value']; $operationResult = wayfarerApplyRuntimeOperation( $normalizedBefore, $operation, $effectValue ); if (!$operationResult['applied']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $normalizedBefore, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $operationResult['error'], ]; } $requestedAfter = $operationResult['value']; $afterBound = wayfarerRuntimeClampValueToTraitDefinition( $requestedAfter, $definition ); if (!$afterBound['valid']) { return [ 'applied' => false, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $normalizedBefore, 'clamped' => $beforeBound['clamped'], 'min' => $beforeBound['min'], 'max' => $beforeBound['max'], 'error' => $afterBound['error'], ]; } return [ 'applied' => true, 'before' => $storedBefore, 'normalized_before' => $normalizedBefore, 'requested_after' => $requestedAfter, 'after' => $afterBound['value'], 'clamped' => $beforeBound['clamped'] || $afterBound['clamped'], 'min' => $afterBound['min'], 'max' => $afterBound['max'], 'error' => '', ]; } function wayfarerRuntimeClampApplicableTraitState( array &$runtimeState, array $traitDefinitions, int $currentRegionId ): array { $repairs = []; // ------------------------------------------------------------ // GAME TRAITS // ------------------------------------------------------------ $gameTraits = $runtimeState['game']['traits'] ?? null; if (is_array($gameTraits)) { foreach ( $traitDefinitions['game'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $gameTraits ) ) { continue; } $before = $runtimeState['game']['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['game']['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'game', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } // ------------------------------------------------------------ // CURRENT REGION TRAITS // ------------------------------------------------------------ $regionKey = (string)$currentRegionId; $regionTraits = $runtimeState['regions'][$regionKey]['traits'] ?? null; if (is_array($regionTraits)) { foreach ( $traitDefinitions['region'] ?? [] as $traitId => $definition ) { if ( !array_key_exists( $traitId, $regionTraits ) ) { continue; } $before = $runtimeState['regions'][$regionKey]['traits'][$traitId]; $bounded = wayfarerRuntimeClampValueToTraitDefinition( $before, is_array($definition) ? $definition : null ); if (!$bounded['valid']) { $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $before, 'clamped' => false, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => $bounded['error'], ]; continue; } if (!$bounded['clamped']) { continue; } $runtimeState['regions'][$regionKey]['traits'][$traitId] = $bounded['value']; $repairs[] = [ 'scope' => 'region', 'target_id' => $traitId, 'before' => $before, 'after' => $bounded['value'], 'clamped' => true, 'min' => $bounded['min'], 'max' => $bounded['max'], 'error' => '', ]; } } return $repairs; } function wayfarerApplyRuntimeTickEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectTickEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection['branch_index'], 'applied' => false, 'error' => $selection['error'], ]; } /* * No conditional branch matched and there was no OTHERWISE. * This is a valid "do nothing this tick" result. */ if (!$selection['matched']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection['branch_index']; $operation = strtolower( trim( (string)( $branch['operation'] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; /* * Explicit No Change: * the branch DID match, so this Tick Effect stops here, but state does * not mutate. */ if ($operation === 'none') { $current = wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $type, $targetId ); return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'amount' => null, 'before' => $current['found'] ? $current['value'] : null, 'after' => $current['found'] ? $current['value'] : null, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'applied' => false, 'no_change' => true, 'error' => '', ]; } /* WAYFARER_TICK_EFFECT_RANDOM_RUNTIME_V58 */ $resolvedValue = wayfarerRuntimeRollEffectValue( $branch ); if (!$resolvedValue['valid']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'applied' => false, 'error' => $resolvedValue['error'], ]; } /* * WAYFARER_BIDIRECTIONAL_TRAVEL_TICK_EFFECT_V71 * * Tick movement is relative to the player's current journey direction. * Only Tick Effects are reversed here. One-time narrative Effects remain * literal state changes, so an authored "Subtract 3 position" effect does * not unexpectedly change meaning because the player is traveling back. */ if ( $scope === 'navigation' && $type === 'position' && $targetId === 'position' && wayfarerRuntimeNavigationDirection( $runtimeState ) < 0 ) { if ($operation === 'add') { $operation = 'subtract'; } elseif ($operation === 'subtract') { $operation = 'add'; } } /* * Apply ONLY the selected result branch. The legacy effect-level * operation/value fields are deliberately ignored when branches exist. */ $resolvedEffect = $effect; $resolvedEffect['operation'] = $operation; $resolvedEffect['value'] = $resolvedValue['value']; $report = wayfarerApplyRuntimeEffect( $runtimeState, $resolvedEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; $report['value_mode'] = $resolvedValue['mode']; $report['rolled_value'] = $resolvedValue['value']; if ($resolvedValue['mode'] === 'random') { $report['random_min'] = $resolvedValue['min']; $report['random_max'] = $resolvedValue['max']; } return $report; } function wayfarerApplyRuntimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $scope = strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ); $targetId = trim( (string)( $effect['target_id'] ?? '' ) ); $operation = strtolower( trim( (string)( $effect['operation'] ?? '' ) ) ); $effectValue = $effect['value'] ?? 0; // ------------------------------------------------------------ // NAVIGATION POSITION // ------------------------------------------------------------ if ( $scope === 'navigation' && $type === 'position' && $targetId === 'position' ) { if ( !isset($runtimeState['navigation']) || !is_array($runtimeState['navigation']) ) { $runtimeState['navigation'] = []; } $before = $runtimeState['navigation']['position'] ?? 0; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); $after = $before; if ( $result['applied'] && !is_numeric($result['value']) ) { $result['applied'] = false; $result['error'] = 'Navigation position must be numeric.'; } if ($result['applied']) { /* * Global navigation rule: * position may never be less than zero. */ $after = max( 0, $result['value'] ); $runtimeState['navigation']['position'] = $after; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $after, 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // GAME TRAIT // ------------------------------------------------------------ if ( $scope === 'game' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['game']) || !is_array($runtimeState['game']) ) { $runtimeState['game'] = []; } if ( !isset($runtimeState['game']['traits']) || !is_array($runtimeState['game']['traits']) ) { $runtimeState['game']['traits'] = []; } $before = $runtimeState['game']['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['game'][$targetId] ) && is_array( $traitDefinitions['game'][$targetId] ) ? $traitDefinitions['game'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); /* * Persist the normalized value even when a malformed operation fails * after an existing illegal value was repaired. */ if ( $result['normalized_before'] !== $before ) { $runtimeState['game']['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['game']['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // ITEM ENTITY QUANTITY // WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 // ------------------------------------------------------------ if ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityScopeMatch ) === 1 && ( (string)( $effect['target_system_key'] ?? '' ) === 'quantity' || $targetId === 'sys_item_quantity' ) ) { $entityKey = (string)( (int)$entityScopeMatch[1] ); if ( !isset( $runtimeState['entities'][$entityKey] ) || !is_array( $runtimeState['entities'][$entityKey] ) || !isset( $runtimeState['entities'][$entityKey]['traits'] ) || !is_array( $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Entity runtime state was not initialized.', ]; } if ( !array_key_exists( $targetId, $runtimeState['entities'][$entityKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Item Quantity runtime state was not found.', ]; } $before = $runtimeState['entities'][$entityKey]['traits'][$targetId]; $result = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); if ( !$result['applied'] || !is_numeric( $result['value'] ?? null ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $before, 'applied' => false, 'error' => $result['error'] !== '' ? $result['error'] : 'Item Quantity must remain numeric.', ]; } $requestedAfter = wayfarerNormalizeRuntimeNumber( (float)$result['value'] ); /* * Quantity is globally non-negative. */ $after = wayfarerNormalizeRuntimeNumber( max( 0.0, (float)$requestedAfter ) ); $runtimeState['entities'][$entityKey]['traits'][$targetId] = $after; /* * Discovery is persistent. Receiving any positive starting quantity * discovers the Item; setting it back to zero never undiscoveres it. */ if ( (float)$after > 0 ) { $runtimeState['entities'][$entityKey]['discovered'] = true; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_system_key' => 'quantity', 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'requested_after' => $requestedAfter, 'after' => $after, 'clamped' => (float)$after !== (float)$requestedAfter, 'min' => 0, 'max' => null, 'applied' => true, 'error' => '', ]; } // ------------------------------------------------------------ // SPECIFIC PLAYER / REGION / LOCATION / ENTITY TRAITS // WAYFARER_RUNTIME_UNIVERSAL_EFFECT_TARGETS_V56 // ------------------------------------------------------------ $stateBucket = null; $stateKey = null; $definition = null; if ( $type === 'trait' && $targetId !== '' && preg_match( '/^player:([0-9]+)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'players'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['player'][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^region:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'regions'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['regions'][$stateKey][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^location:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 ) { $stateBucket = 'locations'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['locations'][$stateKey][$targetId] ?? null; } elseif ( $type === 'trait' && $targetId !== '' && preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $scopeMatch ) === 1 && $targetId !== 'sys_item_quantity' ) { $stateBucket = 'entities'; $stateKey = (string)((int)$scopeMatch[1]); $definition = $traitDefinitions['entities'][$stateKey][$targetId] ?? null; } if ( $stateBucket !== null && $stateKey !== null ) { if ( !isset($runtimeState[$stateBucket][$stateKey]) || !is_array($runtimeState[$stateBucket][$stateKey]) || !isset($runtimeState[$stateBucket][$stateKey]['traits']) || !is_array($runtimeState[$stateBucket][$stateKey]['traits']) || !array_key_exists( $targetId, $runtimeState[$stateBucket][$stateKey]['traits'] ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Runtime Trait target was not found.', ]; } $before = $runtimeState[$stateBucket][$stateKey]['traits'][$targetId]; /* * Party Membership is mutable runtime state, but still a binary * Wayfarer system property. The 0/1 rule is enforced internally and is * never exposed as a separate Target/max metadata value. */ if ( $stateBucket === 'entities' && $targetId === 'sys_character_party_member' ) { $operationResult = wayfarerApplyRuntimeOperation( $before, $operation, $effectValue ); if ( !$operationResult['applied'] || !is_numeric( $operationResult['value'] ?? null ) ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $before, 'applied' => false, 'error' => $operationResult['error'] !== '' ? $operationResult['error'] : 'Party Membership requires a numeric 0/1 result.', ]; } $after = (float)$operationResult['value'] > 0 ? 1 : 0; $runtimeState[$stateBucket][$stateKey]['traits'][$targetId] = $after; return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_system_key' => 'party_member', 'operation' => $operation, 'amount' => $effectValue, 'before' => $before, 'after' => $after, 'applied' => true, 'error' => '', ]; } $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, is_array($definition) ? $definition : null ); if ($result['applied']) { $runtimeState[$stateBucket][$stateKey]['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } // ------------------------------------------------------------ // PLAYER TRAIT // WAYFARER_STARTING_CHOICE_PLAYER_TARGETS_V53 // ------------------------------------------------------------ if ( $scope === 'player' && $type === 'trait' && $targetId !== '' ) { if ( !isset($runtimeState['players']) || !is_array($runtimeState['players']) ) { $runtimeState['players'] = []; } $selector = strtolower( trim( (string)( $effect['target_selector'] ?? '' ) ) ); $targetKeys = []; if ($selector === 'all') { foreach ( $runtimeState['players'] as $playerKey => $playerState ) { if (is_array($playerState)) { $targetKeys[] = (string)$playerKey; } } } elseif ( $selector === 'slot' && array_key_exists( 'target_slot', $effect ) ) { $slot = max( 0, (int)$effect['target_slot'] ); $targetKeys[] = (string)$slot; } else { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect requires an All Players or Player Slot target.', ]; } if (empty($targetKeys)) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Player Trait effect did not resolve any Player Characters.', ]; } $definition = isset( $traitDefinitions['player'][$targetId] ) && is_array( $traitDefinitions['player'][$targetId] ) ? $traitDefinitions['player'][$targetId] : null; $results = []; $firstError = ''; foreach ($targetKeys as $playerKey) { if ( !isset( $runtimeState['players'][$playerKey] ) || !is_array( $runtimeState['players'][$playerKey] ) ) { $firstError = 'Player Character slot ' . ((int)$playerKey + 1) . ' does not exist in runtime state.'; $results[$playerKey] = [ 'applied' => false, 'error' => $firstError, ]; break; } if ( !isset( $runtimeState['players'][$playerKey]['traits'] ) || !is_array( $runtimeState['players'][$playerKey]['traits'] ) ) { $runtimeState['players'][$playerKey]['traits'] = []; } $before = $runtimeState['players'][$playerKey]['traits'][$targetId] ?? 0; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['players'][$playerKey]['traits'][$targetId] = $result['after']; } $results[$playerKey] = [ 'slot' => (int)$playerKey, 'name' => (string)( $runtimeState['players'][$playerKey]['name'] ?? ( 'Player ' . ((int)$playerKey + 1) ) ), 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; if ( !$result['applied'] || $result['error'] !== '' ) { $firstError = $result['error'] !== '' ? $result['error'] : 'Player Trait effect could not be applied.'; break; } } $allApplied = $firstError === ''; return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'target_selector' => $selector, 'target_slot' => $selector === 'slot' ? (int)( $effect['target_slot'] ?? 0 ) : null, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'results' => $results, 'applied' => $allApplied, 'error' => $firstError, ]; } // ------------------------------------------------------------ // CURRENT REGION TRAIT // ------------------------------------------------------------ if ( $scope === 'region' && $type === 'trait' && $targetId !== '' ) { $regionKey = (string)$currentRegionId; if ( !isset($runtimeState['regions']) || !is_array($runtimeState['regions']) ) { $runtimeState['regions'] = []; } if ( !isset($runtimeState['regions'][$regionKey]) || !is_array($runtimeState['regions'][$regionKey]) ) { $runtimeState['regions'][$regionKey] = []; } if ( !isset($runtimeState['regions'][$regionKey]['traits']) || !is_array($runtimeState['regions'][$regionKey]['traits']) ) { $runtimeState['regions'][$regionKey]['traits'] = []; } $before = $runtimeState['regions'][$regionKey]['traits'][$targetId] ?? 0; $definition = isset( $traitDefinitions['region'][$targetId] ) && is_array( $traitDefinitions['region'][$targetId] ) ? $traitDefinitions['region'][$targetId] : null; $result = wayfarerRuntimeApplyBoundedTraitOperation( $before, $operation, $effectValue, $definition ); if ( $result['normalized_before'] !== $before ) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['normalized_before']; } if ($result['applied']) { $runtimeState['regions'][$regionKey]['traits'][$targetId] = $result['after']; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => $result['before'], 'normalized_before' => $result['normalized_before'], 'requested_after' => $result['requested_after'], 'after' => $result['after'], 'clamped' => $result['clamped'], 'min' => $result['min'], 'max' => $result['max'], 'applied' => $result['applied'], 'error' => $result['error'], ]; } return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'amount' => $effectValue, 'before' => null, 'after' => null, 'applied' => false, 'error' => 'Unsupported runtime target.', ]; } /* |-------------------------------------------------------------------------- | Game Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_GAME_TICK_EFFECT_RUNTIME_V33 | | Game Tick Effects execute once on every successful runtime tick before | the active Region's Tick Effects. This creates a deterministic layering: | | Game rules -> Region rules | */ function wayfarerApplyGameTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { if ($gameId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active game is available for Game Tick Effects.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ($rawConfig === false) { $reports = [ [ 'applied' => false, 'error' => 'Game ' . $gameId . ' was not found.', ] ]; if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; return $reports; } $gameConfig = []; if ( $rawConfig !== null && $rawConfig !== '' ) { $decoded = json_decode( (string)$rawConfig, true ); if (is_array($decoded)) { $gameConfig = $decoded; } } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, [] ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $gameEffects = $gameConfig['tick_effects'] ?? []; if (!is_array($gameEffects)) { $gameEffects = []; } $reports = []; foreach ($gameEffects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Game Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset($runtimeState['runtime']) || !is_array($runtimeState['runtime']) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_game_tick_effects'] = $reports; $runtimeState['runtime']['last_game_trait_bound_repairs'] = $boundRepairs; return $reports; } /* |-------------------------------------------------------------------------- | Generalized one-time Effect runtime | WAYFARER_GENERALIZED_EFFECT_RUNTIME_V43 |-------------------------------------------------------------------------- | | Conditions use the same shared evaluator as Tick Effect branches. | Random values are resolved exactly once, immediately before the selected | result mutates runtime state. | */ function wayfarerRuntimeEffectBranches( array $effect ): array { if ( array_key_exists( 'branches', $effect ) ) { if ( !is_array( $effect['branches'] ) ) { return []; } $branches = []; foreach ( $effect['branches'] as $branch ) { if (is_array($branch)) { $branches[] = $branch; } } return $branches; } /* * v42 compatibility: one Effect was one unconditional operation/value. */ if ( array_key_exists( 'operation', $effect ) || array_key_exists( 'value', $effect ) || array_key_exists( 'value_mode', $effect ) || array_key_exists( 'value_min', $effect ) || array_key_exists( 'value_max', $effect ) ) { $branch = [ 'conditions' => null, 'operation' => $effect['operation'] ?? 'add', 'value_mode' => $effect['value_mode'] ?? 'fixed', ]; if ( strtolower( trim( (string)( $branch[ 'value_mode' ] ) ) ) === 'random' ) { $branch['value_min'] = $effect['value_min'] ?? 0; $branch['value_max'] = $effect['value_max'] ?? 0; } else { $branch['value'] = $effect['value'] ?? 0; } return [ $branch ]; } return []; } function wayfarerRuntimeSelectEffectBranch( array &$runtimeState, array $effect, int $currentRegionId ): array { $branches = wayfarerRuntimeEffectBranches( $effect ); if ($branches === []) { return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => 'Effect has no result branches.', ]; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; foreach ( $branches as $branchIndex => $branch ) { $conditions = $branch['conditions'] ?? null; $validation = wayfarerConditionValidateNode( $conditions ); if ( !$validation['valid'] ) { return [ 'matched' => false, 'branch' => null, 'branch_index' => (int)$branchIndex, 'error' => 'Invalid Effect condition: ' . ( $validation['error'] ?? 'unknown condition error' ), ]; } $passes = wayfarerConditionEvaluate( $validation['node'], $resolver ); if (!$passes) { continue; } return [ 'matched' => true, 'branch' => $branch, 'branch_index' => (int)$branchIndex, 'error' => '', ]; } return [ 'matched' => false, 'branch' => null, 'branch_index' => null, 'error' => '', ]; } function wayfarerRuntimeRollEffectValue( array $branch ): array { $mode = strtolower( trim( (string)( $branch[ 'value_mode' ] ?? 'fixed' ) ) ); if ($mode !== 'random') { $value = $branch['value'] ?? null; /* WAYFARER_FIXED_SCALAR_EFFECT_RUNTIME_V58 */ return [ 'valid' => true, 'mode' => 'fixed', 'value' => is_numeric($value) ? wayfarerNormalizeRuntimeNumber( (float)$value ) : $value, 'min' => null, 'max' => null, 'error' => '', ]; } $minimum = $branch['value_min'] ?? null; $maximum = $branch['value_max'] ?? null; if ( !is_numeric($minimum) || !is_numeric($maximum) ) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => $minimum, 'max' => $maximum, 'error' => 'Random Effect requires numeric Min and Max values.', ]; } $min = (float)$minimum; $max = (float)$maximum; if ($min > $max) { return [ 'valid' => false, 'mode' => 'random', 'value' => null, 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => 'Random Effect Min cannot be greater than Max.', ]; } if ( floor($min) === $min && floor($max) === $max ) { $rolled = random_int( (int)$min, (int)$max ); } else { /* * Continuous decimal range. Six decimal places of entropy are more * than sufficient for authored simulation values and preserve generic * numeric Min/Max support without requiring authored precision metadata. */ $fraction = random_int( 0, 1000000 ) / 1000000; $rolled = $min + (($max - $min) * $fraction); } return [ 'valid' => true, 'mode' => 'random', 'value' => wayfarerNormalizeRuntimeNumber( (float)$rolled ), 'min' => wayfarerNormalizeRuntimeNumber( $min ), 'max' => wayfarerNormalizeRuntimeNumber( $max ), 'error' => '', ]; } function wayfarerApplyRuntimeOneTimeEffect( array &$runtimeState, array $effect, int $currentRegionId, array $traitDefinitions = [] ): array { $selection = wayfarerRuntimeSelectEffectBranch( $runtimeState, $effect, $currentRegionId ); $scope = strtolower( trim( (string)( $effect[ 'target_scope' ] ?? '' ) ) ); $type = strtolower( trim( (string)( $effect[ 'target_type' ] ?? '' ) ) ); $targetId = trim( (string)( $effect[ 'target_id' ] ?? '' ) ); if ( $selection['error'] !== '' ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => $selection[ 'branch_index' ], 'applied' => false, 'error' => $selection[ 'error' ], ]; } if ( !$selection['matched'] ) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'matched' => false, 'branch_index' => null, 'applied' => false, 'error' => '', ]; } $branch = $selection['branch']; $branchIndex = (int)$selection[ 'branch_index' ]; $operation = strtolower( trim( (string)( $branch[ 'operation' ] ?? '' ) ) ); $isOtherwise = !array_key_exists( 'conditions', $branch ) || $branch['conditions'] === null || $branch['conditions'] === []; if ($operation === 'none') { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => 'none', 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => null, 'rolled_value' => null, 'applied' => false, 'error' => '', ]; } $resolved = wayfarerRuntimeRollEffectValue( $branch ); if (!$resolved['valid']) { return [ 'scope' => $scope, 'type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'matched' => true, 'branch_index' => $branchIndex, 'branch_kind' => $isOtherwise ? 'otherwise' : 'conditional', 'value_mode' => $resolved[ 'mode' ], 'rolled_value' => null, 'random_min' => $resolved[ 'min' ], 'random_max' => $resolved[ 'max' ], 'applied' => false, 'error' => $resolved[ 'error' ], ]; } $concreteEffect = [ 'target_scope' => $scope, 'target_type' => $type, 'target_id' => $targetId, 'operation' => $operation, 'value' => $resolved[ 'value' ], ]; $report = wayfarerApplyRuntimeEffect( $runtimeState, $concreteEffect, $currentRegionId, $traitDefinitions ); $report['matched'] = true; $report['branch_index'] = $branchIndex; $report['branch_kind'] = $isOtherwise ? 'otherwise' : 'conditional'; $report['value_mode'] = $resolved[ 'mode' ]; $report['rolled_value'] = $resolved[ 'value' ]; if ( $resolved['mode'] === 'random' ) { $report['random_min'] = $resolved[ 'min' ]; $report['random_max'] = $resolved[ 'max' ]; } return $report; } /* |-------------------------------------------------------------------------- | One-time Effect player feedback | WAYFARER_ONE_TIME_EFFECT_FEEDBACK_V70 |-------------------------------------------------------------------------- | | Change events are derived from the Effect report AFTER runtime mutation. | Therefore Random rolls and min/max/non-negative clamps are already reflected | in before/after, and the player sees the quantity that truly changed. | | This layer is intentionally called only by narrative one-time Effect paths. | Tick Effects remain silent. | */ function wayfarerRuntimeFeedbackFindTrait( array $config, string $targetId, string $collection = 'traits' ): ?array { $traits = isset($config[$collection]) && is_array($config[$collection]) ? $config[$collection] : []; foreach ($traits as $trait) { if (!is_array($trait)) { continue; } $id = trim( (string)( $trait['id'] ?? $trait['system_key'] ?? '' ) ); if ($id === $targetId) { return $trait; } } return null; } function wayfarerRuntimeFeedbackTraitIsPlayerFacing(array $trait): bool { $feedback = strtolower( trim( (string)( $trait['feedback'] ?? '' ) ) ); if ($feedback === 'hide') { return false; } if ($feedback === 'show') { return true; } $display = isset($trait['display']) && is_array($trait['display']) ? $trait['display'] : []; $surfaces = isset($display['surfaces']) && is_array($display['surfaces']) ? $display['surfaces'] : []; return !empty($surfaces); } function wayfarerRuntimeFeedbackTraitMetadata( array $trait, string $fallbackLabel, string $ownerLabel = '' ): ?array { if (!wayfarerRuntimeFeedbackTraitIsPlayerFacing($trait)) { return null; } $label = trim( (string)( $trait['name'] ?? $fallbackLabel ) ); if ($label === '') { $label = $fallbackLabel; } $type = strtolower( trim( (string)( $trait['type'] ?? 'number' ) ) ); if ($type === 'binary') { $type = 'boolean'; } return [ 'kind' => 'trait', 'label' => $label, 'owner_label' => $ownerLabel, 'data_type' => $type, ]; } function wayfarerRuntimeFeedbackTargetMetadata( PDO $pdo, int $gameId, array $runtimeState, array $report ): ?array { $scope = strtolower( trim( (string)( $report['scope'] ?? '' ) ) ); $type = strtolower( trim( (string)( $report['type'] ?? '' ) ) ); $targetId = trim( (string)( $report['target_id'] ?? '' ) ); if ( $scope === '' || $targetId === '' || $type !== 'trait' ) { return null; } /* Navigation is deliberately silent even for one-time Effects. */ if ($scope === 'navigation') { return null; } /* -------------------------------------------------------------- * Entity state: Quantity and Party Membership are intrinsically visible. * ----------------------------------------------------------- */ if ( preg_match( '/^entity:([1-9][0-9]*)$/', $scope, $entityMatch ) === 1 ) { $entityId = (int)$entityMatch[1]; $stmt = $pdo->prepare( " SELECT name, entity_type, config FROM wf_entities WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $entityId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $entityName = trim( (string)( $row['name'] ?? ('Entity ' . $entityId) ) ); if ($entityName === '') { $entityName = 'Entity ' . $entityId; } $systemKey = strtolower( trim( (string)( $report['target_system_key'] ?? '' ) ) ); if ( $targetId === 'sys_item_quantity' || $systemKey === 'quantity' ) { return [ 'kind' => 'item_quantity', 'label' => $entityName, 'owner_label' => $entityName, 'data_type' => 'number', ]; } if ( $targetId === 'sys_character_party_member' || $systemKey === 'party_member' ) { return [ 'kind' => 'party_membership', 'label' => $entityName, 'owner_label' => $entityName, 'data_type' => 'boolean', ]; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, $entityName ) : null; } /* -------------------------------------------------------------- * Game Trait. * ----------------------------------------------------------- */ if ($scope === 'game') { $stmt = $pdo->prepare( 'SELECT config FROM wf_games WHERE id = ? LIMIT 1' ); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); if ($raw === false) { return null; } $config = wayfarerRuntimeDecodeTraitConfig($raw); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, 'Game' ) : null; } /* -------------------------------------------------------------- * Specific Region Trait, plus legacy current-Region compatibility. * ----------------------------------------------------------- */ $regionId = 0; if ( preg_match( '/^region:([1-9][0-9]*)$/', $scope, $regionMatch ) === 1 ) { $regionId = (int)$regionMatch[1]; } elseif ($scope === 'region') { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); } if ($regionId > 0) { $stmt = $pdo->prepare( " SELECT name, config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $stmt->execute([ $regionId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, trim((string)($row['name'] ?? 'Region')) ) : null; } /* -------------------------------------------------------------- * Location Trait. * ----------------------------------------------------------- */ if ( preg_match( '/^location:([1-9][0-9]*)$/', $scope, $locationMatch ) === 1 ) { $locationId = (int)$locationMatch[1]; $stmt = $pdo->prepare( " SELECT l.name, l.config FROM wf_locations l INNER JOIN wf_regions r ON r.id = l.region_id WHERE l.id = ? AND r.game_id = ? LIMIT 1 " ); $stmt->execute([ $locationId, $gameId, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $config = wayfarerRuntimeDecodeTraitConfig( $row['config'] ?? null ); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId ); return is_array($trait) ? wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, trim((string)($row['name'] ?? 'Location')) ) : null; } /* -------------------------------------------------------------- * Specific Player Character Trait. * ----------------------------------------------------------- */ if ( preg_match( '/^player:([0-9]+)$/', $scope, $playerMatch ) === 1 ) { $slot = (int)$playerMatch[1]; $stmt = $pdo->prepare( 'SELECT config FROM wf_games WHERE id = ? LIMIT 1' ); $stmt->execute([$gameId]); $raw = $stmt->fetchColumn(); if ($raw === false) { return null; } $config = wayfarerRuntimeDecodeTraitConfig($raw); $trait = wayfarerRuntimeFeedbackFindTrait( $config, $targetId, 'player_traits' ); if (!is_array($trait)) { return null; } $playerName = trim( (string)( $runtimeState['players'][(string)$slot]['name'] ?? ('Player ' . ($slot + 1)) ) ); return wayfarerRuntimeFeedbackTraitMetadata( $trait, $targetId, $playerName ); } return null; } function wayfarerRuntimeBuildEffectChangeEvents( PDO $pdo, int $gameId, array $runtimeState, array $reports ): array { $changes = []; foreach ($reports as $report) { if ( !is_array($report) || empty($report['applied']) ) { continue; } $metadata = wayfarerRuntimeFeedbackTargetMetadata( $pdo, $gameId, $runtimeState, $report ); if (!is_array($metadata)) { continue; } $before = $report['before'] ?? null; $after = $report['after'] ?? null; $kind = (string)($metadata['kind'] ?? 'trait'); $label = trim((string)($metadata['label'] ?? '')); if ($label === '') { continue; } if ($kind === 'party_membership') { if (!is_numeric($before) || !is_numeric($after)) { continue; } $beforeMember = (float)$before > 0; $afterMember = (float)$after > 0; if ($beforeMember === $afterMember) { continue; } $joined = $afterMember; $changes[] = [ 'kind' => 'party_membership', 'direction' => $joined ? 'gain' : 'loss', 'label' => $label, 'before' => $beforeMember ? 1 : 0, 'after' => $afterMember ? 1 : 0, 'delta' => $joined ? 1 : -1, 'text' => $label . ($joined ? ' joined your party.' : ' left your party.'), 'aria_text' => $label . ($joined ? ' joined your party.' : ' left your party.'), ]; continue; } $dataType = strtolower( trim((string)($metadata['data_type'] ?? 'number')) ); if ($dataType === 'boolean') { if (!is_numeric($before) || !is_numeric($after)) { continue; } $beforeBool = (float)$before > 0; $afterBool = (float)$after > 0; if ($beforeBool === $afterBool) { continue; } $changes[] = [ 'kind' => $kind, 'direction' => $afterBool ? 'gain' : 'loss', 'label' => $label, 'before' => $beforeBool ? 1 : 0, 'after' => $afterBool ? 1 : 0, 'delta' => $afterBool ? 1 : -1, 'text' => $label . ': ' . ($afterBool ? 'On' : 'Off'), 'aria_text' => $label . ' is now ' . ($afterBool ? 'on.' : 'off.'), ]; continue; } if (!is_numeric($before) || !is_numeric($after)) { continue; } $deltaFloat = (float)$after - (float)$before; if (abs($deltaFloat) < 0.000000000001) { continue; } $delta = wayfarerNormalizeRuntimeNumber($deltaFloat); $amount = wayfarerNormalizeRuntimeNumber(abs($deltaFloat)); $gain = $deltaFloat > 0; $text = ($gain ? '+' : '-') . (string)$amount . ' ' . $label; if ($kind === 'item_quantity') { $ariaText = ($gain ? 'Gained ' : 'Lost ') . (string)$amount . ' ' . $label . '.'; } else { $ariaText = $label . ($gain ? ' increased by ' : ' decreased by ') . (string)$amount . '.'; } $changes[] = [ 'kind' => $kind, 'direction' => $gain ? 'gain' : 'loss', 'label' => $label, 'before' => $before, 'after' => $after, 'delta' => $delta, 'text' => $text, 'aria_text' => $ariaText, ]; } return $changes; } /* |-------------------------------------------------------------------------- | Location Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_ONE_TIME_EFFECT_RUNTIME_V42 | | These are event Effects, not Tick Effects. The caller decides that a | Location has been triggered and invokes this function exactly once for that | trigger. Effects execute top-to-bottom against current runtime state. | */ function wayfarerApplyLocationEffects( PDO $pdo, int $gameId, array $location, array &$runtimeState ): array { $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : wayfarerRuntimeDecodeTraitConfig( $location['config'] ?? null ); $effects = $locationConfig['effects'] ?? []; if (!is_array($effects)) { $effects = []; } $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false ) { $regionConfig = wayfarerRuntimeDecodeTraitConfig( $rawRegionConfig ); } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeOneTimeEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } $runtimeState['runtime']['last_location_effects'] = [ 'location_id' => $locationId, 'tick' => (int)( $runtimeState['runtime']['tick'] ?? 0 ), 'reports' => $reports, /* WAYFARER_LOCATION_EFFECT_FEEDBACK_V70 */ 'changes' => wayfarerRuntimeBuildEffectChangeEvents( $pdo, $gameId, $runtimeState, $reports ), 'bound_repairs' => $boundRepairs, ]; return $reports; } /* |-------------------------------------------------------------------------- | Current Location Tick Effects |-------------------------------------------------------------------------- | | WAYFARER_LOCATION_TICK_EFFECT_RUNTIME_V35 | | Linear Regions resolve a current Location by exact authored Position, | matching the player-scene resolver. Location Tick Effects run after Region | Tick Effects, so movement performed by the Region can cause arrival effects | at the newly reached Location on the same tick. | */ function wayfarerRuntimeFindCurrentLocation( PDO $pdo, int $gameId, array $runtimeState ): ?array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ( $gameId <= 0 || $regionId <= 0 ) { return null; } $position = $runtimeState['navigation']['position'] ?? null; if (!is_numeric($position)) { return null; } $regionStmt = $pdo->prepare( " SELECT navigation_type FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $regionId, $gameId, ]); $navigationType = $regionStmt->fetchColumn(); if ( $navigationType === false || strtolower( trim( (string)$navigationType ) ) !== 'linear' ) { return null; } $locationStmt = $pdo->prepare( " SELECT * FROM wf_locations WHERE region_id = ? AND status = 'active' ORDER BY id " ); $locationStmt->execute([ $regionId ]); $locations = $locationStmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($locations as $location) { $config = []; if ( !empty( $location['config'] ) ) { $decoded = json_decode( (string)$location['config'], true ); if (is_array($decoded)) { $config = $decoded; } } if ( !array_key_exists( 'position', $config ) || !is_numeric( $config['position'] ) ) { continue; } if ( (float)$config['position'] === (float)$position ) { $location['_runtime_config'] = $config; /* * WAYFARER_LOCATION_CONDITIONS_CURRENT_V45 */ if ( !wayfarerRuntimeLocationConditionsPass( $pdo, $gameId, $location, $runtimeState ) ) { continue; } return $location; } } return null; } function wayfarerApplyCurrentLocationTickEffects( PDO $pdo, int $gameId, array &$runtimeState ): array { $location = wayfarerRuntimeFindCurrentLocation( $pdo, $gameId, $runtimeState ); if ( !isset( $runtimeState['runtime'] ) || !is_array( $runtimeState['runtime'] ) ) { $runtimeState['runtime'] = []; } /* * Being between authored Locations is normal. Record an empty result * rather than treating the absence of a Location as an error. */ if (!$location) { $runtimeState['runtime']['last_location_id'] = null; $runtimeState['runtime']['last_location_tick_effects'] = []; return []; } $locationId = (int)( $location['id'] ?? 0 ); $locationConfig = isset( $location['_runtime_config'] ) && is_array( $location['_runtime_config'] ) ? $location['_runtime_config'] : []; $currentRegionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); /* * Location effects may target Current Region Traits, so load the active * Region's Trait definitions rather than the Location's authored Traits. */ $regionConfig = []; if ($currentRegionId > 0) { $regionStmt = $pdo->prepare( " SELECT config FROM wf_regions WHERE id = ? AND game_id = ? LIMIT 1 " ); $regionStmt->execute([ $currentRegionId, $gameId, ]); $rawRegionConfig = $regionStmt->fetchColumn(); if ( $rawRegionConfig !== false && $rawRegionConfig !== null && $rawRegionConfig !== '' ) { $decoded = json_decode( (string)$rawRegionConfig, true ); if (is_array($decoded)) { $regionConfig = $decoded; } } } $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, $gameId, $regionConfig ); $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $currentRegionId ); $locationEffects = $locationConfig['tick_effects'] ?? []; if (!is_array($locationEffects)) { $locationEffects = []; } $reports = []; foreach ( $locationEffects as $effect ) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed Location Tick Effect.', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $currentRegionId, $traitDefinitions ); } $runtimeState['runtime']['last_location_id'] = $locationId; $runtimeState['runtime']['last_location_tick_effects'] = $reports; $runtimeState['runtime']['last_location_trait_bound_repairs'] = $boundRepairs; return $reports; } /* * WAYFARER_GAME_TRAVEL_MODIFIERS_RUNTIME_V73 * * Game Travel Modifiers are evaluated only when dedicated Linear Travel is * about to move the player. They do not mutate state by themselves and are * intentionally silent in normal Effect feedback. */ function wayfarerRuntimeEvaluateGameTravelModifiers( PDO $pdo, int $gameId, int $currentRegionId, array &$runtimeState ): array { $result = [ 'adjustment' => 0, 'matched' => [], ]; if ($gameId <= 0) { return $result; } $stmt = $pdo->prepare( " SELECT config FROM wf_games WHERE id = ? LIMIT 1 " ); $stmt->execute([ $gameId ]); $rawConfig = $stmt->fetchColumn(); if ( $rawConfig === false || $rawConfig === null || $rawConfig === '' ) { return $result; } $gameConfig = json_decode( (string)$rawConfig, true ); if (!is_array($gameConfig)) { return $result; } $modifiers = $gameConfig['travel']['modifiers'] ?? []; if (!is_array($modifiers)) { return $result; } $resolver = function ( string $scope, string $targetType, string $targetId ) use ( &$runtimeState, $currentRegionId ): array { return wayfarerRuntimeResolveConditionTarget( $runtimeState, $currentRegionId, $scope, $targetType, $targetId ); }; $adjustment = 0.0; foreach ( array_values($modifiers) as $modifier ) { if (!is_array($modifier)) { continue; } $operation = strtolower( trim( (string)( $modifier['operation'] ?? '' ) ) ); $value = $modifier['value'] ?? null; if ( !in_array( $operation, ['add', 'subtract'], true ) || !is_numeric($value) || (float)$value <= 0 ) { continue; } if ( !wayfarerConditionsPass( $modifier['conditions'] ?? null, $resolver ) ) { continue; } $delta = $operation === 'subtract' ? -(float)$value : (float)$value; $adjustment += $delta; $result['matched'][] = [ 'id' => (string)( $modifier['id'] ?? '' ), 'name' => (string)( $modifier['name'] ?? 'Travel Modifier' ), 'operation' => $operation, 'value' => wayfarerNormalizeRuntimeNumber( (float)$value ), 'adjustment' => wayfarerNormalizeRuntimeNumber( $delta ), ]; } $result['adjustment'] = wayfarerNormalizeRuntimeNumber( $adjustment ); return $result; } function wayfarerApplyCurrentRegionTickEffects( PDO $pdo, array &$runtimeState ): array { $regionId = (int)( $runtimeState['navigation']['region_id'] ?? 0 ); if ($regionId <= 0) { $reports = [ [ 'applied' => false, 'error' => 'No active navigation region.', ] ]; /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Reports are returned for the current request, not persisted. */ return $reports; } $stmt = $pdo->prepare( " SELECT id, game_id, config FROM wf_regions WHERE id = ? LIMIT 1 " ); $stmt->execute([ $regionId ]); $region = $stmt->fetch( PDO::FETCH_ASSOC ); if (!$region) { $reports = [ [ 'applied' => false, 'error' => 'Active Region ' . $regionId . ' was not found.', ] ]; /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Reports are returned for the current request, not persisted. */ return $reports; } $config = []; if (!empty($region['config'])) { $decoded = json_decode( $region['config'], true ); if (is_array($decoded)) { $config = $decoded; } } /* WAYFARER_LINEAR_TRAVEL_TICK_PIPELINE_V72 */ $dedicatedTravel = wayfarerRuntimeDedicatedLinearTravel( $config ); $traitDefinitions = wayfarerRuntimeLoadTickTraitDefinitions( $pdo, (int)( $region['game_id'] ?? 0 ), $config ); /* * Enforce the invariant before any branch condition is evaluated. * This also repairs state produced by older runtime versions. */ $boundRepairs = wayfarerRuntimeClampApplicableTraitState( $runtimeState, $traitDefinitions, $regionId ); $effects = $config['tick_effects'] ?? []; if (!is_array($effects)) { $effects = []; } $reports = []; foreach ($effects as $effect) { if (!is_array($effect)) { $reports[] = [ 'applied' => false, 'error' => 'Malformed tick effect.', ]; continue; } /* * WAYFARER_LINEAR_TRAVEL_RUNTIME_V72 * * A dedicated travel Region owns movement through config.travel. * Ignore only Region Tick Effects that target Navigation Position; * every other recurring Region Effect continues normally. */ if ( $dedicatedTravel !== null && strtolower( trim( (string)( $effect['target_scope'] ?? '' ) ) ) === 'navigation' && strtolower( trim( (string)( $effect['target_type'] ?? '' ) ) ) === 'position' && (string)( $effect['target_id'] ?? '' ) === 'position' ) { $reports[] = [ 'scope' => 'navigation', 'type' => 'position', 'target_id' => 'position', 'applied' => false, 'ignored' => true, 'reason' => 'Dedicated Linear Travel owns Region movement.', 'error' => '', ]; continue; } $reports[] = wayfarerApplyRuntimeTickEffect( $runtimeState, $effect, $regionId, $traitDefinitions ); } /* WAYFARER_GAME_TRAVEL_MODIFIERS_RUNTIME_V73 */ if ($dedicatedTravel !== null) { $gameTravelModifierResult = wayfarerRuntimeEvaluateGameTravelModifiers( $pdo, (int)( $region['game_id'] ?? 0 ), $regionId, $runtimeState ); $reports[] = wayfarerRuntimeApplyDedicatedLinearTravelMovement( $runtimeState, $dedicatedTravel, (float)( $gameTravelModifierResult[ 'adjustment' ] ?? 0 ), $gameTravelModifierResult[ 'matched' ] ?? [] ); } /* * WAYFARER_RUNTIME_DIAGNOSTIC_CLEANUP_V55 * Tick reports and bound-repair reports are immediate diagnostics only. */ return $reports; } ================================================================================ FILE: updates/backups/v81-hierarchy-layout-20260929-143545/admin/builder.php ================================================================================ prepare(" SELECT id, name, config FROM wf_games WHERE id = ? "); $stmt->execute([$gameId]); $game = $stmt->fetch(); if (!$game) { die('Game not found.'); } // ================================================================ // LOAD BUILDER DEFINITION // ================================================================ if (!isset($builderTypes[$type])) { die('Unknown builder type.'); } $definition = $builderTypes[$type]; $table = $definition['table']; $listField = $definition['list_field']; $listColumns = $definition['list_columns'] ?? []; $fields = $definition['fields'] ?? []; $relationships = $definition['relationships'] ?? []; $configFields = $definition['config_fields'] ?? []; $scope = $definition['scope']; // ================================================================ // LOAD EDITOR RECORD // ================================================================ $editorState = loadBuilderRecord( $pdo, $definition, $gameId, $id ); $record = $editorState['record']; $configValues = $editorState['configValues']; $traits = $editorState['traits']; $error = ''; /* WAYFARER_STORYLET_BUILDER_V56 * A + New Branch Storylet link can preselect its parent and choose a sensible * sibling order without making parent_storylet_id part of the generic request. */ if ( $type === 'storylets' && !$id && !empty($_GET['parent_storylet_id']) ) { $requestedParentId = (int)$_GET['parent_storylet_id']; $parentStmt = $pdo->prepare( " SELECT id FROM wf_storylets WHERE id = ? AND game_id = ? LIMIT 1 " ); $parentStmt->execute([ $requestedParentId, $gameId, ]); if ($parentStmt->fetchColumn()) { $record['parent_storylet_id'] = $requestedParentId; $orderStmt = $pdo->prepare( " SELECT COALESCE(MAX(branch_sort_order), 0) FROM wf_storylets WHERE game_id = ? AND parent_storylet_id = ? " ); $orderStmt->execute([ $gameId, $requestedParentId, ]); $record['branch_sort_order'] = (int)$orderStmt->fetchColumn() + 10; } } // ================================================================ // SAVE / DELETE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* * WAYFARER DELETE ACTION * * Save remains the default action so existing save forms do not need * an additional hidden field. */ $postAction = $_POST['action'] ?? 'save'; if ($postAction === 'delete') { $deleteId = !empty($_POST['id']) ? (int)$_POST['id'] : 0; $deleteResult = deleteBuilderRecord( $pdo, $definition, $gameId, $deleteId ); $error = $deleteResult['error']; if ($deleteResult['deleted']) { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) ); exit; } } else { $saveResult = saveBuilderRecord( $pdo, $definition, $gameId, $_POST, $record, $configValues, $traits ); $record = $saveResult['record']; $configValues = $saveResult['configValues']; $traits = $saveResult['traits']; $error = $saveResult['error']; if ($saveResult['saved']) { /* * Media Library associations are applied only after the authored * object has saved successfully and therefore has a stable ID. */ $savedRecordId = (int)$saveResult['id']; $mediaResult = applyBuilderMediaSelections( $pdo, $gameId, $definition['table'], $savedRecordId, $_POST, $_FILES ); if (!$mediaResult['saved']) { /* * The authored object itself has already saved. Keep the * editor on that record and report the media failure * accurately rather than pretending the object save failed. */ $id = $savedRecordId; $record['id'] = $savedRecordId; $error = 'Record saved, but media was not changed: ' . $mediaResult['error']; } else { header( 'Location: ' . $builderPage . '?game=' . $gameId . '&type=' . urlencode($type) . '&id=' . (int)$saveResult['id'] . '&tab=' . rawurlencode( (string)($_POST['active_tab'] ?? 'basics') ) ); exit; } } } } // ================================================================ // LOAD COLLECTION + UI SUPPORT DATA // ================================================================ $collectionState = loadBuilderCollection( $pdo, $definition, $gameId ); $records = $collectionState['records']; $relationshipOptions = $collectionState['relationshipOptions']; $conditionData = $collectionState['conditionData']; ?> <?= builderEscape($game['name']) ?> - Wayfarer Builder

Wayfarer Builder

================================================================================ FILE: updates/backups/v81-hierarchy-layout-20260929-143545/admin/games/edit.php ================================================================================ null, 'name' => '', 'slug' => '', 'description' => '', 'instructions' => '', 'player_slots' => 0, 'config' => null, 'status' => 'draft' ]; $traits = []; $playerTraits = []; $config = []; /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ $gameTickEffects = []; $gameplayTickEffects = []; $startingChoices = []; $startingChoicesEditor = []; $startingChoiceItemTargets = []; /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 */ $gameEndings = []; $gameEndingsEditor = []; /* WAYFARER_GAME_TRAVEL_MODIFIERS_V73 */ $gameTravelModifiers = []; $gameTravelModifiersEditor = []; $error = ''; // ================================================================ // LOAD EXISTING GAME // ================================================================ if (isset($_GET['id'])) { $stmt = $pdo->prepare(" SELECT * FROM wf_games WHERE id = ? "); $stmt->execute([(int)$_GET['id']]); $existingGame = $stmt->fetch(); if (!$existingGame) { die('Game not found.'); } $game = array_merge($game, $existingGame); if (!empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } if ( isset($config['traits']) && is_array($config['traits']) ) { $traits = $config['traits']; } /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ if ( isset($config['player_traits']) && is_array($config['player_traits']) ) { $playerTraits = array_values( $config['player_traits'] ); } /* WAYFARER_LOAD_GAME_TICK_EFFECTS_V33 */ if ( isset($config['tick_effects']) && is_array($config['tick_effects']) ) { $gameTickEffects = array_values( $config['tick_effects'] ); } if ( isset($config['setup']['choices']) && is_array($config['setup']['choices']) ) { $startingChoices = array_values( $config['setup']['choices'] ); } /* WAYFARER_GAME_ENDINGS_V45 */ if ( isset($config['endings']) && is_array($config['endings']) ) { $gameEndings = array_values( $config['endings'] ); } /* WAYFARER_LOAD_GAME_TRAVEL_MODIFIERS_V73 */ if ( isset($config['travel']['modifiers']) && is_array($config['travel']['modifiers']) ) { $gameTravelModifiers = array_values( $config['travel']['modifiers'] ); } } $gameplayTickEffects = $gameTickEffects; $startingChoicesEditor = $startingChoices; $gameEndingsEditor = $gameEndings; $gameTravelModifiersEditor = $gameTravelModifiers; // ================================================================ // SAVE // ================================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $game['id'] = !empty($_POST['id']) ? (int)$_POST['id'] : null; $game['name'] = trim($_POST['name'] ?? ''); $game['slug'] = trim($_POST['slug'] ?? ''); $game['description'] = trim($_POST['description'] ?? ''); $game['instructions'] = trim($_POST['instructions'] ?? ''); $game['player_slots'] = max(0, (int)($_POST['player_slots'] ?? 0)); $game['status'] = $_POST['status'] ?? 'draft'; // ------------------------------------------------------------ // BUILD GAME + PLAYER TRAIT DEFINITIONS // WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 // ------------------------------------------------------------ $traitResult = buildTraitsFromPost( $_POST['traits'] ?? [], ['status'] ); $traits = $traitResult['traits']; if ($traitResult['error'] !== '') { $error = $traitResult['error']; } $playerTraitResult = buildTraitsFromPost( $_POST['player_traits'] ?? [], ['status'] ); $playerTraits = $playerTraitResult['traits']; if ( $error === '' && $playerTraitResult['error'] !== '' ) { $error = 'Player Traits: ' . $playerTraitResult['error']; } /* WAYFARER_GAME_SETTINGS_UNIVERSAL_TARGETS_V56 */ $gameplayRuntimeTargets = gameplayRuntimeStateTargets( $pdo, (int)($game['id'] ?? 0), $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ) ); /* WAYFARER_BUILD_GAME_TRAVEL_MODIFIERS_V73 */ $postedGameTravelModifiers = isset($_POST['travel_modifiers']) && is_array($_POST['travel_modifiers']) ? array_values( $_POST['travel_modifiers'] ) : []; $gameTravelModifiersEditor = $postedGameTravelModifiers; /* * Build the editor structure even if another Game Settings section already * has a validation error. This keeps selected Travel Conditions intact on the * failed POST. Only the first validation error becomes the page error. */ $gameTravelModifierResult = wayfarerBuildGameTravelModifiersFromPost( $postedGameTravelModifiers, $gameplayRuntimeTargets ); $gameTravelModifiersEditor = $gameTravelModifierResult[ 'editor_modifiers' ]; if ( $error === '' && $gameTravelModifierResult['error'] !== '' ) { $error = $gameTravelModifierResult['error']; } if ( $gameTravelModifierResult['error'] === '' ) { $gameTravelModifiers = $gameTravelModifierResult[ 'modifiers' ]; } /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_V54 */ $startingChoiceItemTargets = wayfarerStartingChoiceItemQuantityTargets( $pdo, (int)($game['id'] ?? 0) ); /* WAYFARER_BUILD_STARTING_CHOICES_V36 */ /* WAYFARER_STARTING_CHOICES_CANONICAL_EMPTY_V36_1 */ $startingChoicesSubmitted = isset($_POST['setup_choices_present']) || array_key_exists( 'setup_choices', $_POST ); $postedStartingChoices = isset($_POST['setup_choices']) && is_array($_POST['setup_choices']) ? array_values( $_POST['setup_choices'] ) : []; $startingChoicesEditor = $postedStartingChoices; if ($error === '') { $startingChoiceResult = wayfarerBuildStartingChoicesFromPost( $postedStartingChoices, $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ), $startingChoiceItemTargets, $gameplayRuntimeTargets ); if ( $startingChoiceResult['error'] !== '' ) { $error = $startingChoiceResult['error']; } else { $startingChoices = $startingChoiceResult['choices']; $startingChoicesEditor = $startingChoices; } } /* WAYFARER_BUILD_GAME_ENDINGS_V45 */ $postedGameEndings = isset($_POST['endings']) && is_array($_POST['endings']) ? array_values( $_POST['endings'] ) : []; $gameEndingsEditor = $postedGameEndings; if ($error === '') { $endingResult = wayfarerBuildGameEndingsFromPost( $pdo, (int)($game['id'] ?? 0), $postedGameEndings, $traits ); if ( $endingResult['error'] !== '' ) { $error = $endingResult['error']; } else { $gameEndings = $endingResult['endings']; $gameEndingsEditor = $gameEndings; } } /* WAYFARER_BUILD_GAME_TICK_EFFECTS_V33 */ $postedGameTickEffects = isset($_POST['tick_effects']) && is_array($_POST['tick_effects']) ? array_values( $_POST['tick_effects'] ) : []; $gameplayTickEffects = $postedGameTickEffects; if ($error === '') { $gameTickEffectResult = gameplayBuildTickEffectsFromPost( $gameplayRuntimeTargets, $postedGameTickEffects, $gameplayRuntimeTargets ); if ( $gameTickEffectResult['error'] !== '' ) { $error = $gameTickEffectResult['error']; } else { $gameTickEffects = $gameTickEffectResult['effects']; $gameplayTickEffects = $gameTickEffects; } } // ------------------------------------------------------------ // VALIDATE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['name'] === '') { $error = 'Game name is required.'; } elseif ($game['slug'] === '') { $error = 'Slug is required.'; } } // ------------------------------------------------------------ // BUILD CONFIG JSON // ------------------------------------------------------------ if ($error === '') { /* * Preserve any other configuration already stored for the game. * Traits own only the "traits" section of config. */ if (empty($config) && !empty($game['config'])) { $decodedConfig = json_decode( $game['config'], true ); if (is_array($decodedConfig)) { $config = $decodedConfig; } } $startingRegionId = !empty($_POST['starting_region_id']) ? (int)$_POST['starting_region_id'] : null; if ($startingRegionId !== null) { $config['starting_region_id'] = $startingRegionId; } else { unset($config['starting_region_id']); } /* WAYFARER_GAME_TIME_SETTINGS_SAVE_V61 */ $timeMode = strtolower(trim((string)($_POST['time_tracking'] ?? 'none'))); if (!in_array($timeMode, ['none', 'days', 'hours'], true)) { $timeMode = 'none'; } if ($timeMode === 'none') { unset($config['time']); } else { $startingDay = max(1, (int)($_POST['time_starting_day'] ?? 1)); if ($timeMode === 'days') { $config['time'] = [ 'mode' => 'days', 'starting_day' => $startingDay, 'ticks_per_day' => max(1, (int)($_POST['time_ticks_per_day'] ?? 1)), ]; } else { $hoursPerDay = max(1, (int)($_POST['time_hours_per_day'] ?? 24)); $startingHour = max(0, (int)($_POST['time_starting_hour'] ?? 8)); if ($startingHour >= $hoursPerDay) { $startingHour = $hoursPerDay - 1; } $config['time'] = [ 'mode' => 'hours', 'starting_day' => $startingDay, 'starting_hour' => $startingHour, 'ticks_per_hour' => max(1, (int)($_POST['time_ticks_per_hour'] ?? 1)), 'hours_per_day' => $hoursPerDay, ]; } } $config['traits'] = array_values( $traits ); /* WAYFARER_PLAYER_TRAIT_DEFINITIONS_V52 */ $config['player_traits'] = array_values( $playerTraits ); /* WAYFARER_SAVE_GAME_TRAVEL_MODIFIERS_V73 */ if ($gameTravelModifiers) { if ( !isset($config['travel']) || !is_array($config['travel']) ) { $config['travel'] = []; } $config['travel']['modifiers'] = array_values( $gameTravelModifiers ); } else { if ( isset($config['travel']) && is_array($config['travel']) ) { unset( $config['travel']['modifiers'] ); if ($config['travel'] === []) { unset($config['travel']); } } } /* WAYFARER_SAVE_GAME_TICK_EFFECTS_V33 */ if ($gameTickEffects) { $config['tick_effects'] = array_values( $gameTickEffects ); } else { unset( $config['tick_effects'] ); } /* WAYFARER_SAVE_STARTING_CHOICES_V36 */ /* * WAYFARER_STARTING_CHOICES_CANONICAL_EMPTY_V36_1 * * If the Setup editor participated in this submission, its current * collection is the canonical collection. In particular, deleting the * final card must persist an explicit empty array rather than relying * on the absence of setup_choices in POST. */ if ($startingChoicesSubmitted) { if ( !isset($config['setup']) || !is_array($config['setup']) ) { $config['setup'] = []; } $config['setup']['choices'] = array_values( $startingChoices ); } /* WAYFARER_SAVE_GAME_ENDINGS_V45 */ $config['endings'] = array_values( $gameEndings ); $game['config'] = json_encode( $config, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ($game['config'] === false) { $error = 'Unable to encode game configuration.'; } } // ------------------------------------------------------------ // WRITE GAME // ------------------------------------------------------------ if ($error === '') { if ($game['id']) { $stmt = $pdo->prepare(" UPDATE wf_games SET name = ?, slug = ?, description = ?, instructions = ?, player_slots = ?, config = ?, status = ? WHERE id = ? "); $stmt->execute([ $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'], $game['id'] ]); } else { $uuid = bin2hex(random_bytes(16)); $stmt = $pdo->prepare(" INSERT INTO wf_games ( uuid, name, slug, description, instructions, player_slots, config, status ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $uuid, $game['name'], $game['slug'], $game['description'], $game['instructions'], $game['player_slots'], $game['config'], $game['status'] ]); $game['id'] = (int)$pdo->lastInsertId(); } /* * WAYFARER_STAY_ON_GAME_SETTINGS_V38 * * Successful saves remain on the same Game Settings record and reopen * the tab the author was using. */ $savedGameTab = (string)( $_POST['active_tab'] ?? 'basics' ); if ( !in_array( $savedGameTab, [ 'basics', 'setup', 'traits', 'gameplay', 'travel', 'endings', ], true ) ) { $savedGameTab = 'basics'; } header( 'Location: edit.php?id=' . (int)$game['id'] . '&tab=' . rawurlencode( $savedGameTab ) ); exit; } } // ================================================================ // LOAD REGIONS FOR STARTING REGION // ================================================================ $regions = []; if (!empty($game['id'])) { $regionStmt = $pdo->prepare(" SELECT id, name FROM wf_regions WHERE game_id = ? ORDER BY name "); $regionStmt->execute([ $game['id'] ]); $regions = $regionStmt->fetchAll(); } /* WAYFARER_GAME_SETTINGS_UNIVERSAL_TARGETS_RENDER_V56 */ $gameplayRuntimeTargets = gameplayRuntimeStateTargets( $pdo, (int)($game['id'] ?? 0), $traits, $playerTraits, (int)( $game['player_slots'] ?? 0 ) ); /* WAYFARER_STARTING_CHOICE_ITEM_QUANTITY_RENDER_V54 */ $startingChoiceItemTargets = wayfarerStartingChoiceItemQuantityTargets( $pdo, (int)($game['id'] ?? 0) ); ?> <?= $game['id'] ? 'Edit Game' : 'New Game' ?> - Wayfarer


Example: stormgate

================================================================================ FILE: updates/backups/v81-hierarchy-layout-20260929-143545/assets/css/hierarchical-targets.css ================================================================================ /* * Wayfarer Hierarchical Target Picker * WAYFARER_HIERARCHICAL_TARGETS_V80 */ .wayfarer-hierarchy-picker { display: grid; grid-template-columns: repeat(3, minmax(180px, 1fr)); gap: 12px; width: min(980px, 100%); margin-top: 6px; } .wayfarer-hierarchy-level { min-width: 0; } .wayfarer-hierarchy-label { display: block; margin-bottom: 4px; font-weight: 600; } .wayfarer-hierarchy-search, .wayfarer-hierarchy-select { box-sizing: border-box; width: 100%; } .wayfarer-hierarchy-search { margin-bottom: 5px; } .wayfarer-hierarchy-level[hidden] { display: none !important; } /* * The original target select remains in the form as the canonical submitted * field. It is hidden visually so existing PHP/JS storage semantics remain * unchanged. */ .wayfarer-hierarchy-canonical { display: none !important; } .builder-tick-effect-target > .wayfarer-hierarchy-picker, .builder-condition-row > label > .wayfarer-hierarchy-picker { margin-top: 6px; } @media (max-width: 900px) { .wayfarer-hierarchy-picker { grid-template-columns: 1fr; } } ================================================================================ FILE: updates/backups/v81-hierarchy-layout-20260929-143545/assets/js/hierarchical-targets.js ================================================================================ /* * Wayfarer Hierarchical Target Picker * WAYFARER_HIERARCHICAL_TARGETS_V80 * * Progressive enhancement: * - The existing canonical


Example: stormgate

================================================================================ FILE: updates/backups/v82-storylet-root-connections-20260929-153335/assets/js/hierarchical-targets.js ================================================================================ /* * Wayfarer Hierarchical Target Picker * WAYFARER_HIERARCHICAL_TARGETS_V80 * * Progressive enhancement: * - The existing canonical . * Because that select lives inside the original Target